语言基础

基础语法

语言特性

从Go语言的演化来看,可以分为三条主线:从C语言继承了基本数据类型、表达式语法、指针与函数。从Pascal-Oberon-2继承了包管理和面向对象声明语法。从CSP中继承管道通信。

支持特性:代码规范 多返回值 静态类型 符合类型 匿名函数 垃圾回收 包管理系统 UTF8字符串 闭包 系统调用

不支持特性:隐式类型转换 静态变量 指针算术 函数/运算符重载 默认参数 函数修饰构造/析构函数 继承 多态/泛型 异常 宏 线程局部存储 动态链接库

另外Go语言的注释并不总是注释,例如导入C包时,注释可以用来写C语言代码,这就导致注释不能随意清理。

保留关键字

keywords: break      default       func     interface   select
          case       defer         go       map         struct
          chan       else          goto     package     switch
          const      fallthrough   if       range       type
          continue   for           import   return      var
constant: true false iota nil
type:     int int8-64 uint8-64 float32-64 complex64-128
          bool byte rune uintptr string error
function: make len cap new append copy close delete complex real imag
          panic recover

数据类型

  • 基本类型

    变量声明时变量名在类型名之前,支持多变量定义及赋值,多变量赋值的要点是先计算后赋值。简短声明需要注意作用域问题,在内嵌块中用简短声明同名变量时会产生新的变量,这种特性有可能产生很难调试的问题。

    整数类型int的长度由CPU而定,另一个和机器相关的类型是uintptr。字符类型rune等价于uint32,同样byte也是int8的等价类型。

    Go语言不支持隐式类型转换,即便是整型向布尔转换也不行。对于无类型常量,编译器提供至少32字节的运算精度,这个精度大于Go语言中整型量。将无类型常量复制给变量时,可能会发生隐式转换,因为无类型常量也可以认为有一个最佳匹配类型。

    字符串被设计为不可变对象,也就是不能修改字符,字符串末尾没有空字符。字符串支持切片操作。用``定义的字符串不对转义字符转义,支持跨行,这种形式称作原生字符串。要修改字符串可以转换为[]rune[]byte数组再修改,转换会复制内存。遍历字符串用range完成,要按字节遍历可以直接用下标访问。用len()对字符串计算长度得到的是字符串的字节数。

    数组被设计为数值类型,赋值和参数传递会复制整个数组。指针数组[n]*T和数组指针*[n]T表达上看星号位置。因为数组是值传递,所以实际上很少会用到,大多情况都使用切片。

    指针不支持算术运算,但支持解引用和成员操作符,成员操作符统一用点操作,箭头不再具有取成员的功能。比较特别的是返回局部变量的指针是安全的,这种情况编译器会做逃逸分析并视情况选择是否在堆上分配。

    函数new()用于创建对象,并返回一个指针。由于返回自动变量的指针是安全的,所以new()本身可有可无,好处是可以让代码更简洁一点。

    关键字type用于定义新类型,和C语言的typedef相似。但是需要强调即便是用同一个类型衍生的两个类型,也表示不同的类型,因此相互之间不能直接比较和运算。

  • 复合类型

    切片类型和数组在形式上的区别是切片没有指定长度,并且切片不能像数组一样直接进行比较。对数组执行切片操作,就能得到一个切片类型,为了节省内存,切片之后并不会复制内存。

    struct slice
    {
            byte*    array;      // actual data
            uintgo   len;        // number of elements
            uintgo   cap;        // allocated number of elements
    };
    array := [...]int{0, 1, 2, 3, 4, 5, 6}
    slice := array[1:4:5]                    // [beg:end:cap]
    
    append(s, x…)
    用于向slice尾部添加数据,超出cap就会重新分配并复制。
    copy(dst, src)
    复制以长度小的为准,复制本身不会分配内存

    对切片是否为空的测试应利用长度来判断,而不是直接和nil比较。因为存在长度为0但是本身却不为nil的情况。

    映射是基于哈希表的引用类型,支持index、delete操作。索引取回的是复制品,对成员修改并不会作用到映射中的数据,只能是用一个新的值去替换。迭代时可以删除,但是不要新增。

过程控制

  • 表达式

    Go语言在运算符优先级上也做了较大简化,如下所示,一共只有五级。

    %      *      /      <<       >>     &(AND)   &^
    +      -      |(OR)  ^(NOT, XOR)
    ==     !=     <      <=       >      >=
    &&
    ||
    

    对于&^更倾向于认为他们是两个操作的叠加,将^解释为单目按位取反操作。逻辑操作&&||可以更形象比作逻辑乘法和逻辑加法。

    乘除法运算时:若两个参数都是整数,则执行整数运算,否则执行浮点数运算。取模的原则是:结果的符号和被取模的数符号一致。

    算术运算溢出时,高位被丢弃。有符号类型情况比较复杂,如对于int8执行127 + 1得到的结果将是-128

    不支持运算符重载,另外注意++--是语句,而不是表达式。按位取反运算采用^算符,而不是C语言中的~算符。

  • 语句

    对分支语句添加了初始化语句支持,没有?:操作符。

    if init; condition {
            statements
    } else if condition {
            statements
    } else {
            statements
    }
    
    switch expr {
    case value:
            statements
    default:
            statements
    }
    
    switch {
    case condition:
            statements
    default:
            statements
    }
    

    迭代语句range所返回的对象是复制对象,而不是原生对象,当使用引用类型时,底层数据不会被复制。

    Go语言对for语句做了大量重载工作,将原来属于while语句的写法都移植到for语句中了。

    for idx, item := range seqs {           // like for in Python
            statements
    }
    
    for init; condition; post {             // like for in C++
            statements
    }
    
    for condition {                         // like while
            statements
    }
    
    for {                                   // like while(1)
            statements
    }
    

    迭代语句range对不同的序列返回不同:

    string       (i, s[i])
    array/slice  (i, a[i])
    map          (key, m[key])
    channel      e
    

    分支语句switch不再需要写break了,要继续则要显示注明fallthrough。不带表达式的裸swtich语句可以看作是更清爽的if...else if...语句。

    跳转语句break/continue支持带标签形式。

  • 函数

    函数不支持嵌套、重载和默认参数。但支持匿名函数和闭包。

    变参本质上是切片,只能放于最后。使用切片作为变参时,需要展开为s...

    多返回值只能用多变量接收,不能用容器对象接收,但是可以直接传递给函数参数。对于命名返回参数,如果被局部变量遮蔽,就需要显式返回。

    func add(x, y int) (z int) {
            {
                    var z = x + y
                    return                  // ERROR: z is shadowed during return
                    return z
            }
    }
    

    命名返回参数支持defer延迟通过闭包修改。

    func add(x, y int) (z int) {
            defer func() {
                    z += 100
            }()
            z = x + y
            return
    }
    

    匿名函数可以在channel中传送。

    fc := make(chan func() string, 2)
    fc <- func() string { return "Hello, World!" }
    println((<-fc)())
    

    多个defer采用FILO顺序执行。延迟调用主要负责释放资源和错误处理。不要滥用defer功能,滥用defer功能容易引发性能问题。

    panic抛出错误,用recover修复错误。对recover的调用有非常严格的限制,必须在defer函数内直接调用才会终止错误,其它地方调用仅返回nil。

    当匿名函数需要递归调用时,需要先声明变量:

    var visitAll func(items []string)
    visitAll = func(items []string) {
            visitAll(m[item])               // ok
    }
    
    visitAll := func(items []string) {
            visitAll(m[item])               // compile error: undefined: visitAll
    }
    

    在循环中使用匿名函数时要格外小心,下面的问题在于循环变量的作用域,当运行匿名函数时循环已经结束,此时所有线程都是针对最后一个值进行操作:

    var rmdirs []func()
    for _, dir := range tempDirs() {
            os.MkdirAll(dir, 0755)          // ok
            rmdirs = append(rmdirs, func() {
                    os.RemoveAll(dir)       // NOTE: incorrect, dir is the always last one!
            })
    }
    for _, rmdir := range rmdirs {
        rmdir()
    }
    

    正确的操作是:在循环内部声明一个变量,或者定义带参数的匿名函数。

面向对象

  • 数据

    结构体定义用如下格式:

    type Newtype struct {
            member          Int;
    }
    var temp Newtype;                       // define variable
    

    结构体顺序初始化必须包括全部字段,结构体支持匿名字段,匿名成员的真正用途是提供对基类方法调用的简短写法,因此不要滥用匿名成员。

  • 方法

    方法只不过多了一个接收对象,可以理解为函数的第一个隐式参数。对于方法的查找,要注意不支持多级指针查找,也就是说想通过多级指针调用方法,就必须显式声明接收对象为多级指针。

    通过匿名字段可以获得和继承类似的复用能力,利用编译器查找次序可以实现重写。

    对方法调用有两种形式:

    instance.method(args...)                // method value
    <type>.func(instance, args...)          // method expression
    

    method value会复制接收对象,下面的代码中,如果Print()的接收对象是指针,将打印修改后的值,如果Print()的接收对象是值,将打印修改前的值。

    func main() {
            u := User{1, "Tom"}
            uprint := u.Print               // copy receiver
            u.id, u.name = 2, "Jack"
            uprint()
    }
    
  • 接口

    接口本质上就是方法的集合,只要一个类实现了接口中的所有函数,那么就是提供了该接口。如下所示,所有动物都可以定一个Speak()接口。

    type Animal interface {
            Speak() string
    }
    func (c Cat) Speak() string {
            return "Meow!"
    }
    func (j JavaProgrammer) Speak() string {
            return "Design patterns!"
    }
    

项目管理

标准库

包名 说明
bufio 有缓冲的I/O操作
bytes 对[]byte操作,类似strings包
  类似IO缓冲,也实现了Reader/Buffer基于[]byte的缓冲
builtin 内建标识符文档
database.sql 提供SQL数据库接口
encoding 字节/文本转换接口:base64 binary csv json
errors 实现了创建错误值的函数
expvar 提供公共变量的标准接口,读写操作都是原子级的
flag 命令行标签解析
fmt 实现了类似C语言printf和scanf的格式化I/O
io 对I/O原语的基本接口
io.ioutil 实现I/O常用辅助函数
log 简单日志服务
log.syslog 提供一个简单的系统日志服务的接口
math.rand 伪随机数生成器
net 提供了可移植的网络I/O接口
  包括TCP/IP、UDP、域名解析和Unix-socket
net.http HTTP客户端和服务端的实现
net.http.pprof HTTP服务返回runtime的统计数据
os 操作系统函数的不依赖平台的接口
os.exec 执行外部命令
path 实现了对斜杠分隔的路径的实用操作函数
path.filepath 兼容各操作系统的文件路径的实用操作函数
reflect 运行时反射
regexp 正则表达式搜索
sort 为切片及用户定义的集合的排序操作提供了原语
strconv 基本数据类型和其字符串表示的相互转换
strings 用于操作字符的简单函数
sync 提供了互斥锁这类的基本的同步原语
sync.atomic 提供了底层的原子性内存原语
time 时间的显示和测量用的函数
unsafe 包含有关于Go程序类型安全的所有操作

环境工具

常用环境变量如下:

GOROOT
安装位置
GOARCH
目标处理器架构
GOOS
目标主机操作系统
GOBIN
编译器安装位置
GOHOSTOS
目标机器操作系统
GOHOSTARCH
目标机器处理器架构
GOPATH
主路径

常用工具和命令

go get -u -v github.com/nsf/gocode
go get -u -v github.com/rogpeppe/godef
go get -u -v github.com/golang/lint/golint
go get -u -v golang.org/x/tools/cmd/...
go version                              # 查看版本
go env                                  # 查看环境变量
go list                                 # 列出当前安装的包
godoc -http=:4001                       # 生成本地文档
gofmt -w -s -e .                        # 格式化代码
go get -u -v package                    # 下载并更新包
go tool fix                             # 优化更新老版本代码
go tool vet dirs|files                  # 代码检查
go tool vet -shadow dirs|files          # 检查变量覆盖
go test                                 # 运行测试

依赖管理

每个包对应一个独立名字空间,包内的名字如果以大写字符开头那么该名字就是导出的。如果文件被声明为package main就是指定该文件为主程序,当一个包包含多个文件时,构建工具根据文件名排序发给编译器,编译器再逐个初始化。包文件中可以定义一个init()函数来指定初始化顺序。

导入包的几种写法:

import     "sample/test"      // test.A
import  M  "sample/test"      // M.A
import  .  "sample/test"      // A
import  _  "sample/test"      // 仅执行初始化函数

质量管理

测试文件需要命名为:[^.]*_test.go。测试函数分为功能测试和性能测试:

func TestAdd1(t *testing.T)
func BenchmarkAdd1(t *testing.T)

性能测试时,如果想跳过准备阶段,可以在准备之前停止计时器:

func BenchmarkAdd1(b *testing.B) {
        b.StopTimer()
        DoPreparation()
        b.StartTimer()

        for i := 0; i < b.N; i++ {
                Add(1, 2)
        }
}

调试工具

使用pprof只需要两行代码

import _ "net/http/pprof"
go http.ListenAndServe("0.0.0.0:8888", nil)

然后可以通过http://IP:PORT/debug/pprof查看调试信息,也可以用pprof工具生成CPU状态分析图:

# 收集30s数据
go tool pprof http://IP:PORT/debug/pprof/profile
(pprof) top10                           # CPU占用最多的程序
(pprof) web                             # 生成调用图,需要graphviz
(pprof) help                            # 查看帮助信息

编程实践

并发编程

并发编程

在语言层面提供了goroutine,单个进程可以执行成千上万的并发任务,提供channel来通讯。 channel在内部实现提供了同步机制,能确保并发安全,默认采用同步模式,接收和发送必须配对,否则一直阻塞。

func main() {
        data := make(chan int)           // 数据交换队列
        exit := make(chan bool)          // 退出通知
        go func() {
                for d := range data {    // 从队列迭代接收数据,直到 close
                        fmt.Println(d)
                }
                fmt.Println("recv over.")
                exit <- true             // 发出退出通知
        }()
        data <- 1                        // 发送数据
        data <- 2
        data <- 3
        close(data)                      // 关闭队列
        fmt.Println("send over.")
        <-exit                           // 等待退出通知
}

异步方式通过缓冲区来决定是否阻塞,利用缓冲区来提供效率,另一方面应该考虑指针来规避大数据复制。内置函数cap()可以获取缓冲大小,len()可以获取有效元素个数,在并发程序len()的值会不准确。

对已关闭的channel发送数据会引发panic,从已关闭channel接收数据会返回零值,所以关闭应该由生产方控制,接受方也可以通过第二个返回值判断信道是否被关闭,或者用range迭代来自动退出关闭信道。

对于nil channel,收发都会阻塞。

c := make(chan int, 3)
var send chan<- int = c                 // send only
var recv <-chan int = c                 // recv only
send <- 1
<-recv

多信道选择用select完成:

for {
        select {
        case v, ok = <-a: s = "a"
        case v, ok = <-b: s = "b"
        }
        if ok {
                fmt.Println(s, v)
        } else {
                os.Exit(0)
        }
}

for i := 0; i < 5; i++ {
        select {
        case a <- i:
        case b <- i:
        }
}

并发编程的通常做法是建立一个工人组(线程池),他们随时等待活干。有任务时,将任务放到任务池,工人从任务池获取任务。

Go语言runtime包有一个配置项GOMAXPROCS,可以配置Go语言线程最大数量。 Go的线程模型,有 M/P/G 三种对象,分别代表操作系统线程、协程执行令牌、协程;在任何情况下,goroutines数量是小于等于P的数量的。

如果持有P的M因syscall被阻塞,Go调度器需要很久才会找个空闲的M来抢走P。操作系统本身是灵敏的,一旦发现M被阻塞就会调度挂起,但是Go调度器为了减少干预,不会立刻把M持有的P抢走。

这就是为何GOMAXPROCS太小,也就是P的数量太少,会导致IO密集(或者syscall较多)的程序运行缓慢的原因。

如果GOMAXPROCS很大,如超过硬件线程的8倍,也是有开销的,但是远小于Go运行时迟钝的调度M来抢夺P而导致CPU利用不足的开销。

睡眠定时

打点器和time.Sleep()都是借助定时器实现的,其中time.Sleep()包括三步:

  1. 创建定时器
  2. 挂起当前进程
  3. 定时器到期时,使当前进程就绪

这些功能都由time包中提供,基本用例如下,需要注意timer.Stop()和ticker.Stop()不会关闭信道。

func main() {
        timeChan := time.NewTimer(time.Second).C
        tickChan := time.NewTicker(time.Millisecond * 400).C
        doneChan := make(chan bool)
        go func() {
                time.Sleep(time.Second * 2)
                doneChan <- true
        }()

        for {
                select {
                case <-timeChan:
                        fmt.Println("Timer expired")
                case <-tickChan:
                        fmt.Println("Ticker ticked")
                case <-doneChan:
                        fmt.Println("Done")
                        return
                }
        }
}

问题调优

client网络连接泄漏

  1. client在接收http响应后,关闭而不读取Body会导致连接不被释放。占用掉所有6万多个连接后,client端将无法分配新的连接。

信道关闭准则

  1. 在不改变信道的情况下,无法知道信道本身的状态。而关闭一个已经关闭的信道,或者向已经关闭的信道发数据,都会会panic。
  2. 不要在接收端关闭信道;存在多个并发发送端时不要关闭信道。关闭准则:简单的说就是在最后一个发送端发送完数据才可以关闭。
  3. 利用recover:如果确实想在接收端或者存在并发发送端时关闭信道,可以用 recover 机制避免panic。

    func SafeClose(ch chan T) ( bool) {
            defer func() {
                    if err := recover(); err != nil { // panic
                            ok = false
                    }
            }()
    
            close(ch)
            return
    }
    

    由于调用量不大,所以性能损失有限。但是还需要一个SafeSend来避免发送段panic。这种方法还有一个问题是类型不通用。

  4. 利用sync:也可以用全局锁来实现SafeClose()/IsClose()接口,但是很麻烦,也不通用。所以最好的实践方式是遵守Golang的关闭准则。

标准做法:

  1. senders(1)/receivers(n): 发送端控制关闭
  2. senders(n)/receivers(1): 用两个信道,其中一个用于receivers通知发送端不要发送数据, receivers通过关闭信道达到通知目的。数据信道不会关闭,由GC自动回收。

    // senders
    select {
    case <-stopCh:
            return
    case dataCh <- data:
    }
    
    // receivers
    for data := range dataCh {
            if should_stop(data) {
                    close(stopCh)
                    return
            }
    }
    
  3. senders(m)/receivers(n):这种情况要用三个信道,发送和接收都需要同时选择stopCh和dataCh,通过另外一个长度为1的信道来执行关闭stopCh的操作,关闭stopCh可以起到通知所有线程的作用。同样dataCh也不会关闭,由GC自动回收。

程序栈

  1. C/C++/goroutine stack多大呢?是固定的还是动态变化的呢?
  2. stack动态变化的话,什么时候扩容和缩容呢?如何实现的呢?
  3. 对服务有什么影响吗?如何排查栈扩容缩容带来的问题呢?

首先栈的布局从低到高:保留、inited/data、uninited/bss、堆、共享库、栈、内核。 C/C++栈:默认为8MB,相关系统参数可以通过ulimit -a查看,超过就会报段错误。高级用户可以调高内核配置,或者创建线程的时候指定大小。但都不是一个好的方案,好的方案是在调用处插桩,满足就执行,不满足就创建新栈在执行,但是linux线程模型不支持,只能在用户态实现。

Go协程很好的实现了动态栈,初始化大小都是2KB。go1.3之前是分段栈,不足时分配新栈,使用完就释放,递归调用容易引发hot-split。 go1.3之后用的是连续栈,当前栈不足时,分配两倍大小的新栈并复制过去。

在连接数很高时,如果执行rpc调用触发扩容,会使得程序的内容占用成倍增加,有可能因此导致OOM。缩容也有栈拷贝和写屏障,对实时应用有影响。