Go语言高级编程

柴树杉 曹春晖

序一

  • 而基于Go语言构建的Docker、Kubernetes等系统正是将云时代推向顶峰的关键力量。

序二

  • 诠释了Go语言的并发编程哲学的口号:“Do not communicate by sharing memory;instead,share memory by communicating.”(不要通过共享内存来通信,而应通过通信来共享内存)。

1.1 Go语言创世纪

  • 最终的目标是设计网络和多核时代的C语言
  • Go语言的并发特性是由贝尔实验室的Hoare于1978年发布的CSP理论演化而来
  • 最终Go语言演化出了自己特有的支持鸭子面向对象模型的隐式接口等诸多特性。
  • Go语言是对C语言最彻底的一次扬弃,不仅在语法上和C语言有着很多差异,最重要的是舍弃了C语言中灵活但是危险的指针运算。
  • 顺序通信进程(Communicating Sequential Processes,CSP)
  • 源文件采用UTF8编码是Go语言规范所要求的
  • main包中的main()函数默认是每一个可执行程序的入口
  • 在Go语言中,函数参数都是以复制的方式(不支持以引用的方式)传递(比较特殊的是,Go语言闭包函数对外部变量是以引用的方式使用的)。

1.2 “Hello, World”的革命

  • Go语言开始采用是否大小写首字母来区分符号是否可以导出。大写字母开头表示导出的公共符号,小写字母开头表示包内部的私有符号
  • Go语言终于移除了语句末尾的分号
  • Go语言的作者们花了整整32年终于移除了语句末尾的分号。

1.3 数组、字符串和切片

  • 字符串赋值只是复制了数据地址和对应的长度,而不会导致底层数据的复制。
  • 因为数组的长度是数组类型的一部分,不同长度或不同类型的数据组成的数组都是不同的类型,所以在Go语言中很少直接使用数组(不同长度的数组因为类型不同无法直接赋值)
  • 数组的长度以出现的最大的索引为准,没有明确初始化的元素依然用零值初始化。
  • 当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。
  • 因为数组的长度是数组类型的组成部分,指向不同长度数组的数组指针类型也是完全不同的。
  • 不过对数组类型来说,len()和cap()函数返回的结果始终是一样的,都是对应数组类型的长度。
  • 用for range方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形
  • 其中times对应一个[5][0]int类型的数组,虽然第一维数组有长度,但是数组的元素[0]int大小是0,因此整个数组占用的内存大小依然是0。不用付出额外的内存代价,我们就通过for range方式实现times次快速迭代。
  • 字符串数组、结构体数组、函数数组、接口数组、通道数组
  • 长度为0的数组(空数组)在内存中并不占用空间
  • 一般更倾向于用无类型的匿名结构体代替空数组:
  • 我们可以用fmt.Printf()函数提供的%T或%#v谓词语法来打印数组的类型和详细信息:
  • 数组类型是切片和字符串等结构的基础
  • 因为for range等语法并不能支持非UTF8编码的字符串的遍历。
  • 字符串结构由两个信息组成:第一个是字符串指向的底层字节数组;第二个是字符串的字节的长度。字符串其实是一个结构体,因此字符串的赋值操作也就是reflect.StringHeader结构体的复制过程,并不会涉及底层字节数组的复制。
  • 字符串虽然不是切片,但是支持切片操作,不同位置的切片底层访问的是同一块内存数据
  • 如果遇到一个错误的UTF8编码输入,将生成一个特别的Unicode字符'\uFFFD',这个字符在不同的软件中的显示效果可能不太一样,在印刷中这个符号通常是一个黑色六角形或钻石形状,里面包含一个白色的问号“�”。
  • 错误编码不会向后扩散是UTF8编码的优秀特性之一
  • rune用于表示每个Unicode码点,目前只使用了21个位。
  • 字符串相关的强制类型转换主要涉及[]byte和[]rune两种类型
  • 因为底层内存结构的差异,所以字符串到[]rune的转换必然会导致重新分配[]rune内存空间,然后依次解码并复制对应的Unicode码点值。
  • 切片(slice)就是一种简化版的动态数组
  • 但是切片多了一个Cap成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)
  • 切片可以和nil进行比较,只有当切片底层数据指针为空时切片本身才为nil,这时候切片的长度和容量信息将是无效的。
  • 切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。
  • 在容量不足的情况下,append ()操作会导致重新分配内存,可能导致巨大的内存分配和复制数据的代价
  • 在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制一次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。
  • 由于append()函数返回新的切片,也就是它支持链式操作,因此我们可以将多个append ()操作组合起来,实现在切片中间插入元素:
  • 用copy()和append()组合可以避免创建中间的临时切片
  • append()本质是用于追加元素而不是扩展容量,扩展切片容量只是append()的一个副作用。
  • 所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化
  • 在判断一个切片是否为空时,一般通过len获取切片的长度来判断,一般很少将切片和nil做直接的比较。
  • 但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟垃圾回收器对底层数组的回收。
  • 这段代码返回的[]byte指向保存整个文件的数组。由于切片引用了整个原始数组,导致垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然不是传统意义上的内存泄漏,但是可能会降低系统的整体性能。
  • 数据的传值是Go语言编程的一个哲学,虽然传值有一定的代价,但是换取的好处是切断了对原始数据的依赖
  • 保险的方式是先将指向需要提前回收内存的指针设置为nil,保证垃圾回收器可以发现需要回收的对象,然后再进行切片的删除操作
  • 因为float64遵循IEEE 754浮点数标准特性,所以当浮点数有序时对应的整数也必然是有序的
  • Go语言实现中非0大小数组的长度不得超过2GB

1.4 函数、方法和接口

  • 当匿名函数引用了外部作用域中的变量时就成了闭包函数,闭包函数是函数式编程语言的核心。
  • 函数主要有具名和匿名之分,包级函数一般都是具名函数,具名函数是匿名函数的一种特例。
  • 在语法上,函数还支持可变数量的参数,可变数量的参数必须是最后出现的参数,可变数量的参数其实是一个切片类型的参数。
  • 不仅函数的参数可以有名字,也可以给函数的返回值命名:
  • 闭包对捕获的外部变量并不是以传值方式访问,而是以引用方式访问。
  • 第一种方法是在循环体内部再定义一个局部变量,这样每次迭代defer语句的闭包函数捕获的都是不同的变量,这些变量的值对应迭代时的值。第二种方式是将迭代变量通过闭包函数的参数传入,defer语句会马上对调用参数求值。
  • 任何可以通过函数参数修改调用参数的情形,都是因为函数参数中显式或隐式传入了指针参数。
  • 如果被调用函数中修改了Len或Cap信息,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是内置的append ()必须要返回一个切片的原因。
  • 虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白Go语言中指针不再是固定不变的(因此不能随意将指针保存到数值变量中,Go语言的地址也不能随意保存到不在垃圾回收器控制的环境中,因此使用CGO时不能在C语言中长期持有Go语言对象的地址)
  • 我们无法知道函数参数或局部变量到底是保存在栈中还是堆中,我们只需要知道它们能够正常工作就可以
  • 同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。
  • 但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定
  • Go语言中的做法是将函数CloseFile()和ReadFile()的第一个参数移动到函数名的开头:
  • 这样的话,函数CloseFile()和ReadFile()就成了File类型独有的方法了(而不是File对象方法)
  • 每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)
  • 通过称为方法表达式的特性可以将方法还原为普通类型的函数
  • Go语言中,通过在结构体内置匿名的成员来实现继承
  • 通过嵌入匿名的成员,不仅可以继承匿名成员的内部成员,而且可以继承匿名成员类型所对应的方法
  • 但是在调用p.Lock()和p.Unlock()时,p并不是方法Lock()和Unlock()的真正接收者,而是会将它们展开为p.Mutex.Lock()和p.Mutex.Unlock()调用。这种展开是编译期完成的,并没有运行时代价。
  • 而在Go语言通过嵌入匿名的成员来“继承”的基类方法,this就是实现该方法的类型的对象,Go语言中方法是编译时静态绑定的。如果需要虚函数的多态特性,我们需要借助Go语言接口来实现。
  • 它在提供严格的类型检查的同时,通过接口类型实现了对鸭子类型的支持,使得安全动态的编程变得相对容易。
  • 所谓鸭子类型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子
  • Go语言对基础类型的类型一致性要求可谓是非常的严格,但是Go语言对于接口类型的转换则非常灵活。对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。
  • 再严格一点的做法是给接口定义一个私有方法。只有满足了这个私有方法的对象才可能满足这个接口,而私有方法的名字是包含包的绝对路径名的,因此只有在包内部实现这个私有方法才能满足这个接口
  • 因为接口方法是延迟绑定,所以编译时私有方法是否真的存在并不重要。
  • Go语言通过几种简单特性的组合,就轻易实现了鸭子面向对象和虚拟继承等高级特性,真的是不可思议。

1.5 面向并发的内存模型

  • 顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。
  • Go语言是基于消息并发模型的集大成者,它将基于CSP模型的并发编程内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,与Erlang不同的是,Go语言的Goroutine之间是共享内存的。
  • Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动
  • 一个Goroutine会以一个很小的栈启动(可能是2KB或4KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)
  • Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。
  • 所谓的原子操作就是并发编程中“最小的且不可并行化”的操作
  • 一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护
  • 标准库的sync/atomic包对原子操作提供了丰富的支持。
  • 互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。
  • 但是Go语言并不保证在main()函数中观测到的对done的写入操作发生在对字符串a的写入操作之后,因此程序很可能打印一个空字符串。
  • 在Go语言中,同一个Goroutine线程内部,顺序一致性的内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性的内存模型,需要通过明确定义的同步事件来作为同步的参考。
  • 根据Go语言规范,main()函数退出时程序结束,不会等待任何后台线程。
  • 解决问题的办法就是通过同步原语来给两个事件明确排序
  • 通过sync.Mutex互斥量也是可以实现同步
  • 当一个包被导入时,如果它还导入了其他的包,则先将其他的包包含进来,然后创建和初始化这个包的常量和变量。再调用包里的init()函数,如果一个包有多个init()函数,实现可能是以文件名的顺序调用,那么同一个文件内的多个init()是以出现的顺序依次调用的(init()不是普通函数,可以定义多个,但是不能被其他函数调用)
  • 因为所有的init()函数和main()函数都是在主线程完成,它们也是满足顺序一致性模型的。
  • 无缓存的通道上的发送操作总在对应的接收操作完成前发生。
  • 若在关闭通道后继续从中接收数据,接收者就会收到该通道返回的零值
  • 对于带缓存的通道,对于通道中的第K个接收完成操作发生在第K+C个发送操作完成之前,其中C是管道的缓存大小。
  • 我们可以根据控制通道的缓存大小来控制并发执行的Goroutine的最大数目
  • 最后一句select{}是一个空的通道选择语句,该语句会导致main线程阻塞,从而避免程序过早退出。还有for{}、<-make(chan int)等诸多方法可以达到类似的效果。因为
  • 严谨的并发也应该是可以静态推导出结果的:根据线程内顺序一致性,结合通道或sync事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。

1.6 常见的并发模式

  • 通信顺序进程(Communicating Sequential Process,CSP)
  • 作为Go并发编程核心的CSP理论的核心概念只有一个:同步通信。
  • 并发不是并行。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面,并行一般是简单的大量重复
  • 不要通过共享内存来通信,而应通过通信来共享内存
  • 并发编程的核心概念是同步通信
  • 当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,main()函数的阻塞状态驱动后台线程继续向前执行。
  • 根据Go语言内存模型规范,对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。
  • 在传统生产者/消费者模型中,是将消息发送到一个队列中,而发布/订阅模型则是将消息发布给一个主题。
  • 在发布/订阅模型中,每条消息都会传送给多个订阅者。发布者通常不会知道,也不关心哪一个订阅者正在接收主题消息。订阅者和发布者可以在运行时动态添加,它们之间是一种松散的耦合关系,
  • 在Go语言自带的godoc程序实现中有一个vfs的包对应虚拟的文件系统,在vfs包下面有一个gatefs的子包,gatefs子包的目的就是为了控制访问该虚拟文件系统的最大并发数。
  • 当select()有多个分支时,会随机选择一个可用的通道分支,如果没有可用的通道分支,则选择default分支,否则会一直保持阻塞状态。
  • 当有多个通道均可操作时,select会随机选择一个通道
  • 其实我们可以通过close()关闭一个通道来实现广播的效果,所有从关闭通道接收的操作均会收到一个零值和一个可选的失败标志。
  • 在Go 1.7发布时,标准库增加了一个context包,用来简化对于处理单个请求的多个Goroutine之间与请求域的数据、超时和退出等操作

1.7 错误和异常

  • Go语言推荐使用recover()函数将内部异常转为错误处理,这使得用户可以真正地关心业务相关的错误处理。
  • 我们可以通过defer语句来确保每个被正常打开的文件都能被正常关闭:
  • 为了提高系统的稳定性,Web框架一般会通过recover来防御性地捕获所有处理流程中可能产生的异常,然后将异常转为普通的错误返回。
  • Go语言库的实现习惯:即使在包内部使用了panic,在导出函数时也会被转化为明确的错误值。
  • Go语言中interface是一个例外:非接口类型到接口类型,或者接口类型之间的转换都是隐式的
  • 当函数调用panic()抛出异常时,函数将停止执行后续的普通语句,但是之前注册的defer()函数调用仍然保证会被正常执行,然后再返回到调用者
  • 在异常发生时,如果在defer()中执行recover()调用,它可以捕获触发panic()时的参数,并且恢复到正常的执行流程。
  • 都是经过了两个函数帧才到达真正的recover()函数,这个时候Goroutine对应的上一级栈帧中已经没有异常信息。
  • 必须要和有异常的栈帧只隔一个栈帧,recover()函数才能正常捕获异常

第2章 CGO编程

  • Go语言通过自带的一个叫CGO的工具来支持C语言函数调用,同时我们可以用Go语言导出C动态库接口给其他语言使用。

2.1 快速入门

  • 没有释放使用C.CString创建的C语言字符串会导致内存泄漏。

2.2 CGO基础

  • import "C"导入语句需要单独占一行,不能与其他包一同import。
  • 传递前必须用"C"中的转换函数转换成对应的C类型,不能直接传入Go中类型的变量
  • 在import "C"语句前的注释中可以通过#cgo语句设置编译阶段和链接阶段的相关参数。

2.7 CGO内存模型

  • 在CGO调用的C语言函数返回前,CGO保证传入的Go语言内存在此期间不会发生移动,C语言函数可以大胆地使用Go语言的内存!

4.1 RPC入门

  • RPC是远程过程调用的简称,是分布式系统中不同节点间流行的通信方式。
  • Go语言的RPC规则:方法只能有两个可序列化的参数,其中第二个参数是指针类型,并且返回一个error类型,同时必须是公开的方法。
  • 首先是服务的名字,然后是服务要实现的详细方法列表,最后是注册该类型服务的函数
  • 标准库的RPC默认采用Go语言特有的Gob编码
  • Go语言的RPC框架有两个比较有特色的设计:一个是RPC数据打包时可以通过插件实现自定义的编码和解码;另一个是RPC建立在抽象的io.ReadWriteCloser接口之上,我们可以将RPC架设在不同的通信协议之上。

4.2 Protobuf

  • 其实用Protobuf定义与语言无关的RPC服务接口才是它真正的价值所在!

5.1 Web开发简介

  • 根据我们的经验,简单来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用net/http中默认的路由

5.2 请求路由

  • Go语言圈子里路由器也时常称为http的多路复用器。
  • 所以在路由中的参数数目不能超过255,否则会导致httprouter无法识别后续的参数。
  • httprouter和众多衍生路由库使用的数据结构被称为压缩动态检索树(Compressing Dynamic Trie)

5.3 中间件

  • 对大多数的场景来讲,非业务的需求都是在HTTP请求处理前做一些事情,并且在响应完成之后做一些事情

5.6 服务流量限制

  • 流量限制的手段有很多,最常见的有漏桶和令牌桶两种。
  • QoS全称是Quality of Service,顾名思义是服务质量。QoS包含可用性、吞吐量、延时、延时变化和丢失等指标。

5.8 接口和表驱动开发

  • 业务发展的早期,是不适宜引入接口(interface)的,很多时候业务流程变化很大,过早引入接口会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。

6.2 分布式锁

  • 活锁指的是程序看起来在正常执行,但实际上CPU周期被浪费在抢锁而非执行任务上,从而导致程序整体的执行效率低下。活锁的问题定位起来要麻烦很多,所以在单机场景下,不建议使用这种锁。
  • 基于ZooKeeper的锁与基于Redis的锁的不同之处在于Lock成功之前会一直阻塞,这与单机场景中的mutex.Lock很相似。
  • 这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。

6.3 延时任务系统

  • 定时器(timer)的实现在工业界已经是有解了。常见的就是时间堆和时间轮。
  • Go自身的内置定时器就是用时间堆来实现的
  • 每次转动到一个刻度时,就需要去查看该刻度挂载的任务列表是否有已经到期的任务

A.8 独占CPU导致其他Goroutine饿死

  • Goroutine是协作式抢占调度,Goroutine本身不会主动放弃CPU:

A.11 在循环内部执行defer语句

  • defer在函数退出时才能执行,在for执行defer会导致资源延迟释放

A.12 切片会导致整个底层数组被锁定

  • 切片会导致整个底层数组被锁定,底层数组无法释放内存。