password
Created
Feb 19, 2023 04:46 PM
type
Post
status
Published
date
Dec 4, 2022
slug
summary
Go语言实战读书笔记
tags
Go
Go语言实战
category
Go
icon
打包和工具链包包命名main包导入远程导入命名导入函数init数组, 切片和映射数组声明和初始化切片映射类型系统用户定义的类型方法类型的本质接口实现方法集多态嵌入类型并发并发与并行线程与进程goroutine锁住共享资源互斥锁通道runnerpoolwork
打包和工具链
包
所有的.go 文件,除了空行和注释,都应该在第一行声明自己所属的包。
每个包都在一个单独的目录里。不能把多个包放到同一个目录中,也不能把同一个包的文件分拆到多个不同目录中。
同一个目录下的所有.go 文件必须声明同一个包名。
包命名
给包命名的惯例是使用包所在目录的名字。
应该使用简洁、清晰且全小写的名字,这有利于开发时频繁输入包名。
并不需要所有包的名字都与别的包不同,因为导入包时是使用全路径的,所以可以区分同名的不同包。
main包
Go 语言里,命名为 main 的包具有特殊的含义。Go 语言的编译程序会试图把这种名字的包编译为二进制可执行文件。
所有用 Go 语言编译的可执行程序都必须有一个名叫 main 的包。
main()函数是程序的入口,所以,如果没有这个函数,程序就没有办法开始执行。
程序编译时,会使用声明 main 包的代码所在的目录的目录名作为二进制可执行文件的文件名。
导入
编译器会使用 Go 环境变量设置的路径,通过引入的相对路径来查找磁盘上的包。
标准库中的包会在安装 Go 的位置找到。Go 开发者创建的包会在 GOPATH 环境变量指定的目录里查找。
GOPATH 指定的这些目录就是开发者的个人工作空间。
GOPATH是一个以冒号隔开(windows下用分号)组合而成的字符串
包的查找顺序是这样的:
- 查找Go安装目录的
src/pkg
下的包
- 查找第一个GOPATH中的环境变量路径下的src目录下的包
- 查找第二个GOPATH中的环境变量路径下的src目录下的包
- …
一旦编译器找到一个满足 import 语句的包,就停止进一步查找。有一件重要的事需要记住,编译器会首先查找 Go 的安装目录,然后才会按顺序查找 GOPATH 变量里列出的目录。
远程导入
如果路径包含 URL,可以使用 Go 工具链从DVCS 获取包,并把包的源代码保存在 GOPATH 指向的路径里与 URL 匹配的目录里。
这个获取过程使用 go get 命令完成。go get 将获取任意指定的 URL 的包,或者一个已经导入的包所依赖的其他包。
命名导入
导入包有相同的名字, 则需要使用命名导入的方式来重命名同名包
import ( "fmt" myfmt "mylib/fmt" )
函数init
每个包可以包含任意多个 init 函数,这些函数都会在程序执行开始的时候被调用。所有被
编译器发现的 init 函数都会安排在 main 函数之前执行。
init 函数用在设置包、初始化变量或者其他要在程序运行前优先完成的引导工作。
数组, 切片和映射
数组
在 Go 语言里,数组是一个长度固定的数据类型,用于存储一段具有相同的类型的元素的连续块。数组存储的类型可以是内置类型,如整型或者字符串,也可以是某种结构类型。
声明和初始化
- 声明一个数组, 并设置为零值
var array [5]int
- 使用数组字面量声明数组
array := [5]int{10, 20, 30, 40, 50}
- 自动计算声明数组的长度
array := [...]int{10, 20, 30, 40, 50}
- 声明数组并指定特定元素值
array := [5]{1: 10, 2: 20}
- 访问指针数组的元素
array := [5]*int{0: new(int), 1: new(int)} *array[0] = 10 *array[1] = 20
- 多维数组
array := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}} // 声明单个元素 array := [4][2]int{1: { 0: 20 }, 3: { 1: 40 }}
切片
切片是一种数据结构,这种数据结构便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数 append 来实现的。这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。
- 使用长度声明切片
slice := make([]string, 5)
- 创建长度和容量声明切片
slice := make([]int, 3, 5) // 长度为3个元素, 容量为5个元素
此切片可以访问三个元素, 底层数组有5个元素, 剩余的元素可以在后面合并到此切片
- 通过切片字面量来声明切片(与数组声明的不同是方括号中没有数字代表的长度//)
// 其长度和容量都是5个元素 slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"} // 其长度和容量都是3个元素 slice := []int{10, 20, 30}
- 使用索引声明切片
slice := []string{99: ""} // 初始化了第100个元素
- 创建nil切片
var slice []int
- 创建空切片
slice := make([]int, 0) slice := []int{}
- 使用切片创建切片
slice := []int{1,2,3,4,5} newSlice := slice[1:3] // 2, 3, 4
- 使用append增加元素
slice := []int{1,2,3,4,5} newSlice := slice[1:3] // 切片是左闭右开区间 newSlice = append(newSlice, 10) // [1,2,3,10,5] 修改会影响到原始数组
需要注意的是, 如果切片刚好是原始切片或数组的长度, append也能成功, 但只有新切片会增加一个数, 原数组就不会增长了
函数 append 会智能地处理底层数组的容量增长。在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量。
- 使用三个索引创建切片
slice := source[2,3,4] // 长度为1个元素(3-2) 容量为2个元素{4-2}
- 设置长度和容量一致的好处
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"} slice := source[2:3:3] slice = append(slice, "Kiwi")
这里我们限制了切片的长度和目前的内容是相等的, 这个时候我们调用append, 会在切片上新增一个
Kiwi
值, 且不会改动到元数据- 将一个切片追加到另一个切片
s1 := []int{1, 2} s2 := []int{3, 4} fmt.Printf("%v\n", append(s1, s2...)) // [1 2 3 4]
- 迭代切片
slice := []int{1, 2, 3, 4} for index, value := range slice { fmt.Printf("Index: %d Value: %d\n", index, value) // Index: 0 Value 1 etc... }
当迭代切片时,关键字 range 会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本
此处的value并不是原切片中的某一个值, 而是一个新的存储空间
slice := []int{1, 2, 3, 4} for index := 2; index < len(slice); index++ { fmt.Printf("Index: %d Value: %d\n", index, slice[index]) } // output: // Index: 2 Value: 3 // Index: 3 Value: 4
- 组合切片的切片
slice := [][]int{{10}, {100, 200}} slice[0] = append(slice[0], 20) // [{10, 20}, {100, 200}]
映射
映射是一个集合,可以使用类似处理数组和切片的方式迭代映射中的元素。
但映射是无序的集合,意味着没有办法预测键值对被返回的顺序。即便使用同样的顺序保存键值对,每次迭代映射的时候顺序也可能不一样。
- 使用make声明映射
dict := make(map[string]int) // 直接创建 dict := map[string]string{"Red": "#xxx", "Orange": "#xxx"}
- 映射赋值
colors := map[string]string{} colors["Red"] = "#xxx"
- 判断映射值是否存在
value, exists := colors["Blue"] if exists { // XXX } // or use value := colors["Blue"] if value !== "" { // XXX }
- 使用range迭代映射
for key, value := range colors { fmt.Printf("Key: %s Value: %s\n", key, value) }
- 删除映射的某一项
delete(colors, "Coral") // 只能用在映射存储的值都是非零值的情况
- 在函数间传递映射
在函数间传递映射并不会制造出该映射的一个副本。实际上,当传递映射给一个函数,并对这个映射做了修改时,所有对这个映射的引用都会察觉到这个修改
类型系统
用户定义的类型
Go 语言里声明用户定义的类型有两种方法。最常用的方法是使用关键字 struct,它可以让用户创建一个结构类型
- 定义一个结构类型
type user struct { name string email string ext int provileged bool }
这个声明以关键字 type 开始,之后是新类型的名字,最后是关键字 struct。
- 使用结构类型声明变量, 并初始化为其零值
var bill user
- 使用结构字面量来声明一个结构类型的变量
lisa := user{ name: "Lisa", email: "lisa@email.com", ext: 123, privileged: true, }
- 不适用字段名来创建结构类型的值
lisa := user{"Lisa", "lisa@email.com", 123, true}
值的顺序很重要,必须要和结构声明中字段的顺序一致
- 使用其他结构类型声明字段
type admin struct { person user level string }
- 使用结构字面量来创建字段值
fred := admin { person: user { name: "Lisa", email: "lisa@email.com", ext: 123, privileged: true, }, level: "super", }
- 基于原始类型创建一个新类型
type Duration int64
Go 并不认为 Duration 和 int64 是同一种类型。这两个类型是完全不同的有区别的
类型
方法
方法能给用户定义的类型添加新的行为。方法实际上也是函数,只是在声明时,在关键字func 和方法名之间增加了一个参数
type user struct { name string email string }
- 值接受者
func (u user) notify() { // ... }
- 指针接受者
func (u *user) changeEmail(email string) { u.email = email }
类型的本质
如果是要创建一个新值,该类型的方法就使用值接收者。
如果是要修改当前值,就使用指针接收者。
接口
多态是指代码可以根据类型的具体实现采取不同行为的能力。如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。
实现
接口是用来定义行为的类型。这些被定义的行为不由接口直接实现,而是通过方法由用户定义的类型实现。
如果用户定义的类型实现了某个接口类型声明的一组方法,那么这个用户定
义的类型的值就可以赋给这个接口类型的值。
这个赋值会把用户定义的类型的值存入接口类型的值。
对接口值方法的调用会执行接口值里存储的用户定义的类型的值对应的方法。因为任何用户定义的类型都可以实现任何接口,所以对接口值方法的调用自然就是一种多态。
在这个关系里,用户定义的类型通常叫作实体类型,原因是如果离开内部存储的用户定义的类型的值的实现,接口值并没有具体的行为。
方法集
方法集定义了一组关联到给定类型的值或者指针的方法。定义方法时使用的接收者的类型决定了这个方法是关联到值,还是关联到指针,还是两个都关联。
多态
嵌入类型
Go 语言允许用户扩展或者修改已有类型的行为。这个功能对代码复用很重要,在修改已有类型以符合新类型的时候也很重要。这个功能是通过嵌入类型(type embedding)完成的。
嵌入类型是将已有的类型直接声明在新的结构类型里。被嵌入的类型被称为新的外部类型的内部类型。
通过嵌入类型,与内部类型相关的标识符会提升到外部类型上。这些被提升的标识符就像直接声明在外部类型里的标识符一样,也是外部类型的一部分。
// 如果一个文件中定义的类型struct是这样 type User struct { Name string email string } // 在另一个文件中调用 u := entities.User { Name: "Bill", email: "bill@email.com" // error }
如上所示, 如果我们对未公开的类型进行赋值时, 会产生报错
但是对于嵌入类型未公开, 但是其内部类型是公开的, 则可以这样处理, 例子如下:
type User struct { Name: string Email: string } type Admin struct { user Rights int } // 使用 a := entities.Admin { Rights: 10 } // 通过点语法为内嵌类型的公开类型赋值 a.Name = "Bill" a.Email = "bill@email.com"
并发
Go 语言里的并发指的是能让某个函数独立于其他函数运行的能力。
当一个函数创建为 goroutine时,Go 会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。
Go 语言的并发同步模型来自一个叫作通信顺序进程(Communicating Sequential Processes,CSP) 的范型(paradigm)。
CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。用于在 goroutine 之间同步和传递数据的关键数据类型叫作通道(channel)。
并发与并行
线程与进程
当运行一个应用程序(如一个 IDE 或者编辑器)的时候,操作系统会为这个应用程序启动一个进程。
一个线程是一个执行空间,这个空间会被操作系统调度来运行函数中所写的代码。
每个进程至少包含一个线程,每个进程的初始线程被称作主线程。当主线程终止时,应用程序也会终止。
在 1.5 版本之前的版本中,默认给整个应用程序只分配一个逻辑处理器。
如果创建一个 goroutine 并准备运行,这个 goroutine 就会被放到调度器的全局运行队列中。
之后,调度器就将这些队列中的 goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列
本地运行队列中的 goroutine 会一直等待直到自己被分配的逻辑处理器执行。
有时,正在运行的 goroutine 需要执行一个阻塞的系统调用,如打开一个文件。
当这类调用发生时,线程和 goroutine 会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。
调度器会创建一个新线程,并将其绑定到该逻辑处理器上。
之后,调度器会从本地运行队列里选择另一个 goroutine 来运行。一旦被阻塞的系统调用执行完成并返回,对应的 goroutine 会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。
如果一个 goroutine 需要做一个网络 I/O 调用,流程上会有些不一样。在这种情况下,goroutine会和逻辑处理器分离,并移到集成了网络轮询器的运行时。
一旦该轮询器指示某个网络读或者写操作已经就绪,对应的 goroutine 就会重新分配到逻辑处理器上来完成操作。
调度器对可以创建的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建 10 000 个线程。
并发(concurrency)不是并行(parallelism)。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。
goroutine
goroutine使用的是并行的处理方式
在其中, 可以使用GOMAXPROCS函数来传入并行处理的逻辑处理器数量
runtime.GOMAXPROCS(1)
如上, 此时指定的最大逻辑处理器数量为1
如果设置逻辑处理器为1时, 还是任务将不会从当前逻辑处理器中移除, 而是会继续等待直到执行完毕, 因此当两个goroutine一起启动的时候, 前面的耗时任务会阻塞后面的任务
另: 一般我们使用WaitGroup来进行goroutine计数以保证主线程不会在goroutine执行完毕前被退出
var wg sync.WaitGroup // 声明需要等待2个goroutine wg.add(2) go func(){ defer wg.Done() }() go func(){ defer wg.Done() }() wg.Wait()
锁住共享资源
var ( counter int64 wg sync.WaitGroup ) func main() { wg.add(2) go incCounter(1) go incCounter(2) wg.Wait() fmt.Println("Final Counter:", counter) } func incCounter(id: int) { defer wg.Done() for count := 0; count < 2; count++ { atomic.AddInt64(&counter, 1) // 当前goroutine从线程退出, 并放回到队列 runtime.Gosched() } }
以上示例中使用到了`atomic.AddInt64`方法, 强制同一时刻只能有一个goroutine运行并完成这个加法操作.
当goroutine视图调用任何子函数时, 这些goroutine都会自动根据所引用的变量做同步处理.
另外两个有用的原子函数是 LoadInt64 和 StoreInt64。这两个函数提供了一种安全地读和写一个整型值的方式。
互斥锁
互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界区代码。
var ( counter int wg sync.WaitGroup mutex sync.Mutex ) func main() { wg.Add(2) go.incCounter(1) go.incCounter(2) wg.Wait() fmt.Printf("Final Counter: %d\n", counter) } func incCounter(id: int) { defer wg.Done() for count := 0; count < 2; count++ { // 同一时刻只允许一个goroutine进入这个临界区 mutex.Lock() { // 捕获counter的值 value := counter // 当前goroutine从线程退出, 放回队列 runtime.Gosched() // 增加本地value变量的值 value++ // 将该值保存到counter counter = value } // 释放锁, 允许其他正在等待的goroutine进入临界区 mutex.Unlock() } }
通道
以使用通道,通过发送和接收需要共享的资源,在 goroutine 之间做同步
当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。
声明通道时,需要指定将要被共享的数据的类型。可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。
- 使用make创建通道
unbuffered := make(chan int) buffered := make(chan string, 10)
- 向通道发送值
buffered := make(chan string, 10) buffered <- "Gopher"
- 从通道接收值
value := <-buffered
runner
runner 包用于展示如何使用通道来监视程序的执行时间,如果程序运行时间太长,也可以用 runner 包来终止程序。
pool
用于展示如何使用有缓冲的通道实现资源池,来管理可以在任意数量的goroutine之间共享及独立使用的资源。
work
work 包的目的是展示如何使用无缓冲的通道来创建一个 goroutine 池,这些 goroutine 执行并控制一组工作,让其并发执行。