Go 语言基础
整理 GOPL 一书在实际编程中最为核心的内容。
1. 并发编程
1.1. goroutine
Go 语言对并发的支持非常友好,下面是一个经典的网络并发实例。在异步处理的模式下,一个 goroutine 就可以负责单独处理一个网络连接。
package main
import (
"io"
"log"
"net"
"time"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
panic(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
// 同步处理当前的连接
// handleConn()
// 通过goroutine异步处理当前的连接
go handleConn(conn)
}
}
func handleConn(c net.Conn) {
defer c.Close()
for {
_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
if err != nil {
return
}
time.Sleep(1 * time.Second)
}
}
当运行该 server 程序后,可通过客户端访问 server 进程,如nc localhost 8000
。在同步处理的模式下, 只有终止了一个 nc 进程之后,才可以让另一个 nc 进程顺利工作。但在异步处理的模式下,两个 nc 进程可以同时接收到 server 端返回的时间。
sync.WaitGroup
是一种特殊的计数器类型,它可被多个 goroutinbe 安全地操作,可用于等待一组并发的 goroutine 完成。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
wg.Add(2) // 计划等待2个goroutine完成
go func() {
defer wg.Done() // 在goroutine结束时调用Done减少计数
fmt.Println("Starting first goroutine")
time.Sleep(2 * time.Second)
fmt.Println("First goroutine finished")
}()
go func() {
defer wg.Done()
fmt.Println("Starting second goroutine")
time.Sleep(3 * time.Second)
fmt.Println("Second goroutine finished")
}()
// 使用Wait方法阻塞,直到WaitGroup的计数器归零
wg.Wait()
fmt.Println("Both goroutines have finished execution.")
}
1.2. 基于 channel 并发
当不同的 goroutine 需要协作时,就要求 goroutine 之间能以某种方式进行通信,第一种方式是 channel。
channel 的零值是 nil,关于 nil channel 和关闭后的 channel 的基本性质如下:
在 nil 通道上发送和接收将会永远阻塞
关闭后的 channel 发送操作将导致 panic,接收操作将获取所有已经发送的值,直到通道为空,这之后任何的接收操作将会立即返回元素类型的零值
1.2.1. 无缓冲通道
读取一个无缓冲 channel 将会阻塞,直到有 goroutine 向其中写数据;对应地,写一个无缓冲的 channel 也会导致阻塞,直到有 goroutine 从其中读数据 。也就是说,使用无缓冲通道进行通信将使得发送和接收是同步的,因此无缓冲通道也叫同步通道。以下方式创建的都是无缓冲通道。
ch = make(chan int)
ch = make(chan int, 0)
下面是一个多 channel 与 goroutine 构成通信管道的实例。
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for x := 0; x < 100; x++ {
naturals <- x
}
close(naturals)
}()
// go func() {
// for {
// x, ok := <-naturals
// if !ok { // 该方式可以判断channel是否已经读完且关闭
// break
// }
// squares <- x * x
// }
// close(squares)
// }()
// for {
// x, ok := <-squares
// if !ok {
// break
// }
// fmt.Println(x)
// }
go func() {
for x := range naturals { // for range语法读channel,当channel关闭时会退出循环
squares <- x * x
}
close(squares)
}()
for x := range squares {
fmt.Println(x)
}
}
1.2.2. 缓冲通道
缓冲通道有“容量”(cap)的概念,如果通道满了,写将会阻塞;如果通道为空,读将会阻塞。
以下是一个使用缓冲通道进行通信的实例,注意该实例不能使用无缓冲通道,否则会导致后两个请求成功的 goroutine 发生永久阻塞,且这两个阻塞的 goroutine 不会被 go 运行时回收,因而导致 goroutine 泄漏。
func mirroredQuery() string {
responses := make(chan string, 3)
go func() { responses <- request("url1") }
go func() { responses <- request("url2") }
go func() { responses <- request("url3") }
return <- responses
}
1.2.3. select 多路复用
select 的每一个情况会指定一次通信,select 一直等待,直到有一些通信可以进行。换句话说,如果没有对应的通信可以进行,select 将会永远等待。如果多个情况同时满足,select 会随机选择一个。
package main
import (
"fmt"
"os"
"time"
)
func main() {
abort := make(chan struct{})
go func() {
os.Stdin.Read(make([]byte, 1)) // read a single byte
abort <- struct{}{}
}()
fmt.Println("commencing countdown. Press return to abort.")
select {
case <-time.After(10 * time.Second):
fmt.Println("Lift off!")
case <-abort:
fmt.Println("Launch aborted!")
return
}
}
select 可以使用 default 分支来实现非阻塞通信,比如下面添加的 default 分支实现了对通道的轮询,当 abort channel 没有准备好消息时,select 会不断地进入 default 分支。
for {
select {
case <- abort:
fmt.Println("Launch aborted!")
return
default:
}
}
值得注意的是,由于关闭的 channel 接收操作是会读取数据(如果有的话)并立即返回的 ,所以 channel 的关闭操作也可以让 select 成功执行对应的分支操作。
1.3. 基于共享变量并发
竞态(race condition)是指多个 goroutine 按某些交错顺序执行时程序无法给出正确的结果。基于共享变量并发需要避免数据竞态(data race),其发生在两个 goroutine 并发读写同一个变量且至少有一个是写入时。
1.3.1. 互斥锁
互斥锁通过 Lock 和 Unlock 操作来保护共享变量,在二者之间的部分称为“临界区”(代码实例略)。
当绝大部分 goroutine 都只需要读变量时,可以使用读写锁。但在锁竞争并不激烈时,它比普通的互斥锁要慢,因为其内部有更复杂的记账工作。
1.3.2. 内存同步
现代编译器和 CPU 的工作方式可能导致意想不到的结果。考虑下面的代码片段:
var x, y int
go func() {
x = 1
fmt.Printf("y:", y, " ")
}()
go func() {
y = 1
fmt.Printf("x:", x, " ")
}()
很容易想到可能有以下输出结果:
y:0 x:1
y:1 x:1
x:0 y:1
x:1 y:1
但实际上还有可能出现:
x:0 y:0
y:0 x:0
这可能由两种原因导致:
两个 goroutine 在不同的 cpu 核上并行执行,变量 x、y 的值只在 cpu 缓存中更新了,而并未刷到内存中,导致两个 goroutine 看到的变量都是旧的值;或者
编译器交换了 goroutine 内部两条语句的执行顺序,因为编译器认为这没有影响
因此,对于并发读写共享变量的场景,都需尽可能地使用互斥锁来避免数据竞态。
2. 组合
Go 支持面向对象编程,但并不属于经典 OO 语言的范畴。Go 是如何将程序的各个部分联系起来的呢?答案是“组合”,这是 Go 面向对象编程的核心,或者说是 Go 很重要的一条设计哲学。
2.1. 结构体
结构体中的变量可以通过结构体或者结构体指针来访问,均使用点号。如果结构体定义中的成员变量的顺序不一致,或者组合形式不一致,将被视为不同的结构体。临时结构体变量是不能被赋值的,故以下代码将无法通过编译。
func getEmployee() Employee { /* ... */ }
getEmployee().age = 2 // 无法通过编译
如果结构体中的所有成员变量都可以比较,那么这个结构体就是可比较的,由于可比较的类型都可以作为 map 的key 类型,所以可比较的结构体类型可作为 map 的 key 类型。
2.1.1. 结构体嵌套
结构体嵌套机制可以让一个命名结构体类型或者其指针类型成为另一个结构体类型的匿名成员。结构体嵌套的好处是可以从外围结构体直接访问到匿名成员中的变量,而不需要指定一大串的中间变量。不过,包含匿名成员的结构体在初始化时并没有什么快捷方式,仍需一板一眼地按照结构体的定义来初始化。
2.2. 方法
type Path []Point
func (p Path) Distance() float64 { /* ... */ }
如果方法需要更新类型变量本身,或者类型变量太大而需要避免整体复制,可以让方法使用指针接收者。习惯上,如果某类型的任何一个方法使用了指针接收者,那么该类型的所有方法都应该使用指针接收者。
如果方法的接收者是指针变量,形式上仍然可以通过结构体变量进行调用,但实际编译器会将结构体变量隐式地转换成指针变量。自然地,对于无法取地址的值不能调用使用指针接收者的方法。
func (p *Point) ScaleBy(factor float64) { /* ... */ } // 接收者是指针变量
p := Point{1, 2} // p 是结构体类型变量
p.ScaleBy(2) // 编译器会隐式转换成 (&p).ScalyBy(2)
外围的结构体类型获取的不仅是匿名成员的内部变量,还有相关的方法。但除了结构体类型,Go 可以将方法绑定到任何类型上。
值得注意的是,内嵌结构体与外围结构体的关系并非派生类与基类的关系,虽然外围结构体可以直接调用内嵌结构体的方法,但是以内嵌结构体类型为形参的函数并不能直接将外围结构体变量作为实参。
p.Distance(q.Point) // 函数 p.Distance 必须显示地使用变量 q.Point 为实参
2.3. 接口
2.3.1. 接口的动态类型与动态值
编译器可以判断某类型是否实现了目标接口类型。
// bytes 包中定义了 Buffer struct 类型
var _ io.Writer = &bytes.Buffer{}
var _ io.Writer = (*bytes.Buffer)(nil)
从概念上讲,一个接口类型的值包含两部分:一个具体类型和该类型的一个值。接口的零值为二者均为 nil,调用一个 nil 接口的任何方法都会导致崩溃。接口值可以用 == 和 != 进行比较,如果两个接口值都是 nil,或者二者的动态类型与动态值都相等,则认为两个接口值相等。如果两个接口值的动态类型一致,但是动态值不可比较,那么比较导致 panic。例如,以下比较将导致 panic。
var a, b interface{}
a = []int{1, 2}
b = []int{1, 2, 3}
if a == b { // []int 不可比较,程序会 panic!
fmt.Println("a equals to b")
}
需要注意的是,nil 接口与仅仅动态值为 nil 的接口值是不一样的,对于以下实例,当 debug = false 时,程序并不会像预期的那样什么也不做,而是会直接 panic。
package main
import (
"bytes"
"io"
)
const debug = true
func main() {
var buf *bytes.Buffer
if debug {
buf = &bytes.Buffer{}
}
f(buf)
}
func f(w io.Writer) {
// 如果 debug 为 false,此时接口 w 并不为 nil,
// 因为其已经具有动态类型,仅仅是动态值为 nil
if w != nil {
w.Write([]byte("done!\n"))
}
}
2.3.2. 接口嵌套
与结构体一样,一个接口类型也可以嵌入到其它接口类型的定义中。如下,Reader 和 Writer 两个接口以内嵌接口的形式组合成了 ReadWriter 接口。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// 组合
type ReadWriter interface {
Reader
Writer
}
2.3.3. 空接口与类型断言
由于空接口类型 interface{} 对其实现类型没有任何要求,所以我们可以将任何值赋值给空接口类型。
var any interface{}
any = true
any = 12.34
any = map[string]int{"one": 1}
虽然空接口类型的变量可以被随意赋值,但仅仅靠赋值后的变量本身并不能做什么,我们还需要从空接口中还原出实际的值,这需要用到类型断言。
断言的形式为 x.(T)。如果断言类型 T 是一个具体类型,会检查 x 的动态类型是否为 T。若断言成功,将会从接口中提取出动态值,若失败会 panic。如果断言类型 T 也是一个接口类型,则会检查 x 的动态类型是否满足接口 T。当断言成功时并不会提取动态值,断言的结果仍然是一个接口,且动态类型和动态值都没有发生变化,失败仍然会 panic。nil 接口的任何断言都会失败。
// 若断言类型是具体类型
var w io.Writer
w = new(bytes.Buffer)
c := w.(*bytes.Buffer) // 断言成功
f := w.(*os.File) // 断言失败,程序 panic
// 若断言类型是接口类型
// ...
如果要避免在类型断言时发生 panic,可以使用双返回值的断言语句。
f, ok := w.(*os.File) // 若失败,f 为 nil,ok 为 false
3. 参考资料
《Go 程序设计语言》