Clone
11
GoDevGuide
winlin edited this page 2022-01-06 11:57:15 +08:00
This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Go开发关键技术指南

本文从服务器领域的问题本身出发并不局限于Go语言探讨服务器中常常遇到的问题每个问题回到Go如何解决从而提供Go开发的技术指南。服务器领域的问题包括常见的并发等问题也涉及到了工程化相关的问题也整理了C背景程序员对于Go的GC以及性能的疑问探讨了Go的错误处理和类型系统最佳实践以及依赖管理的难处接口设计的正交性当然也包含我们在RTC服务器中对于Go实践的总结有时候也对一些有趣的问题做深度的挖掘也列出了Go重要的事件和资料集合还有Go2的进展和思考。

本文主要发布于简书欢迎来简书阅读本文。完整的Markdown也在github

image.png

原图链接地址:https://www.processon.com/view/link/5df22829e4b010171a411e7d#map

感谢阿里巴巴云原生微信公众号转载了这篇文章给了很重要的改进建议从Markdown转微信公众号也做了大量工作配图和排版让阅读变成一种享受。如果你更习惯微信阅读可以读下面四章

Overview

本文讨论了服务器领域常见的并发等问题也涉及到了工程化相关的问题也整理了C背景程序员对于Go的GC以及性能的疑问探讨了Go的错误处理和类型系统最佳实践以及依赖管理的难处接口设计的正交性当然也包含我们在服务器开发中对于Go实践的总结有时候也对一些有趣的问题做深度的挖掘也列出了Go重要的事件和资料集合还有Go2的进展和思考。我更想从问题本身出发不局限于Go语言探讨服务器中常常遇到的问题最后回到Go如何解决这些问题提供Go开发的关键技术指南。

下面是各个章节以及简介:

  • About the Name 为何Go有时候也叫Golang?
  • Why Go? 为何要选择Go作为服务器开发的语言是冲动还是骚动
  • Milestones Go的重要里程碑和事件当年吹的那些牛逼都实现了哪些
  • Could Not Recover 君可知有什么panic是无法recover的包括超过系统线程限制以及map的竞争写。当然一般都能recover比如Slice越界、nil指针、除零、写关闭的chan等。
  • Errors 为什么Go2的草稿3个有2个是关于错误处理的好的错误处理应该怎么做错误和异常机制的差别是什么错误处理和日志如何配合
  • Logger 为什么标准库的Logger是完全不够用的怎么做日志切割和轮转怎么在混成一坨的服务器日志中找到某个连接的日志甚至连接中的流的日志怎么做到简洁又够用
  • Type System 什么是面向对象的SOLID原则为何Go更符合SOLID为何接口组合比继承多态更具有正交性Go类型系统如何做到looser, organic, decoupled, independent, and therefore scalable
  • Orthogonal 一般软件中如果出现数学要么真的牛逼要么装逼。正交性这个数学概念在Go中频繁出现是神仙还是妖怪为何接口设计要考虑正交性
  • Modules 如何避免依赖地狱(Dependency Hell)小小的版本号为何会带来大灾难Go为什么推出了GOPATH、Vendor还要搞module和vgo新建了16个仓库做测试碰到了9个坑搞清楚了gopath和vendor如何迁移以及vgo with vendor如何使用毕竟生产环境不能每次都去外网下载
  • Concurrency 服务器中的并发处理难在哪里为什么说Go并发处理优势占领了云计算开发语言市场什么是C10K、C10M问题
  • Context 如何管理goroutine的取消、超时和关联取消为何Go1.7专门将context放到了标准库context如何使用以及问题在哪里
  • Engineering Go在工程化上的优势是什么为什么说Go是一门面向工程的语言覆盖率要到多少比较合适什么叫代码可测性为什么良好的库必须先写Example
  • Go2 Transition Go2会像Python3不兼容Python2那样作吗C和C++的语言演进可以有什么不同的收获Go2怎么思考语言升级的问题
  • GC Go的GC靠谱吗Twitter说相当的靠谱有图有真相
  • Declaration Syntax 为何Go的声明语法是那样C的又是怎样是拍的大腿还是拍的脑袋
  • Documents Go官网的重要文档分类本屌丝读了四遍了推荐阅读
  • SRS Go在流媒体服务器中的使用。

About the Name

The Go Programming Language到底是该叫GO还是GOLANGGoogle搜Why Go is called Golang能搜到几篇经典帖子。

Rob Pike在Twitter上特意说明是Go可以看这个The language is called Go:

Neither. The language is called Go, not Golang. http://golang.org is just the the web site address, not the name of the language.

那么在另外一个地方也说明了也是Go可以看这个The name of our language is go

The name of our language is Go
Ruby is called Ruby, not Rubylang.
Python is called Python, not Pythonlang.
C is called C, not Clang. No. Wait. That was a bad example.
Go is called Go, not Golang.

Yes, yes, I know all about the searching and meta tags. Sure, whatever,
but that doesn't change the fact that the name of the language is Go.

Thank you for your consideration.

这里举了各种例子说明为何不加lang的后缀当然有个典型的语言是加的就是Erlang。于是就有回复说“Erlang Erlang, Let's just call it Er.”

那么为何很多时候Go和Golang都很常用呢Why is the Go programming language usually called Golang中说的比较清楚:

Its because “go domain” has been registered by Walt Disney and so Go creators couldnt use it.
So, they have decided to use golang for the domain name. Then the rest came.

Also, its harder to search things on search engines just using the word Go. Although, Rob Pike is
against this idea but I disagree. Most of the time, for the correct results you need to search for
golang.

Its just Go, not golang but it sticked to it.

讲个笑话先,用百度搜下为何Go叫做Golang一大片都是类似本文的鸡汤煲告诉你为何Go才是天地间最合适你的语言当然本文要成为鸡汤煲中的战斗煲告诉你全家都应该选择Go语言。

为何Go语言名字是Go但是经常说成是Golang呢有以下理由

  1. go.org被注册了正在卖也不贵才1698万。所以Go只能用golang.org
  2. 搜点啥信息如果搜go太宽泛了特别是go还没有这么多用户时搜golang能更精确的找到答案。

为什么在名字上要这么纠结呢?嗯嗯,不纠结,让我们开始干鸡汤吧。

Why Go?

考虑一个商用的快速发展的业务后端服务器最重要的是什么当然是稳定性了如果崩溃可能会造成用户服务中断崩溃的问题在C/C++服务器中几乎是必然的:

  1. 稳定是一种假象。想象一个C服务器一般不会重头码所有的代码会从一个开源版本开始或者从一些网络和线程库开始然后不断改进和完善由于业务前期并不复杂上线也没有发现问题这时候可以说C服务器是稳定的吗当然不是只是Bug没有触发而已所有崩溃的Bug都几乎不是本次发布导致的问题。野指针和越界是C服务器中最难搞定的狼人这些狼人还喜欢玩潜伏。
  2. 稳定是短暂不稳定是必然和长期。一般业务会突飞猛进特别是越偏上层的业务需要后端处理的逻辑就越多至于UTest和测试一般只存在于传说中随着业务的发展潜伏的狼人越来越多甚至开源的库和服务器中的狼人也开始出来作妖。夜路走多了总会碰到鬼。碰到鬼了怎么办遇鬼杀鬼了还能被它吓尿不成所以就反思解决Bug费了老劲了又白了几根头发终于迎来短暂安宁然后继续写Bug。
  3. 最普遍的问题还是内存问题导致崩溃一般就是野指针和越界。空指针问题相对很容易查除零之类的典型错误也容易处理。最完善的解决办法就是实现GC让指针总是有效无效后再释放越界时能检测到这样容易解决问题其实Go早期的版本就和这个很类似了要实现带GC的C的同学可以参考下Go的实现。
  4. 线上的CPU和内存的问题一般不方便使用工具查看而线上的问题有时候很难在本地重现。如何能直接获取线上的Profile数据需要程序本身支持。比如提供HTTP API能获取到Profile数据关键是如何采集这些数据。

Go的使命愿景和价值观

Go is an open source programming language that makes it easy to build simple, reliable, and efficient software.

Go is a concurrent open source programming language developed at Google. Combines native compilation and static types with a lightweight dynamic feel. Fast, fun, and productive.

Go is an attempt to make programmers more productive. The first goal is to make a better language to meet the challenges of scalable concurrency. The larger goal is to make a better environment to meet the challenges of scalable software development, software worked on and used by many people, with limited coordination between them, and maintained for years.

Go语言的关键字

  • 运行性能高: Statically typed. Native code generation (compiled). Efficiency. Fast development cycle.
  • 码农不苦逼: Memory safe. Garbage collected. Safety.
  • 云计算专享: Native concurrency support. Concurrency. Scalability.
  • 工程师思维: Composition via interfaces. Excellent standard library. Great tools.

参考The Path to Go1: What is Go?Another Go at Language Design

参考Go: a simple programming environment

Go是面向软件工程的语言Go在工程上的思考可以读Go at Google: Language Design in the Service of Software EngineeringLess is exponentially more。Go最初是解决Google遇到的大规模系统和计算的问题这些问题如今被称为云计算参考Go, Open Source, Community

GITHUT上显示Go的项目和PR一直在上升如下图所示。

image.png

2014云计算行业中使用Go的有Docker, Kubernetes, Packer, Serf, InfluxDB, Cloud Foundrys gorouter and CLI, CoreOSs etcd and fleet, Vitess, YouTubes tooling for MySQL scaling, Canonicals Juju (rewritten in Go), Mozillas Heka, A Go interface to OpenStack Swift, Herokus Force.com and hk CLIs, Apceras NATS and gnatsd。

2018年全球使用Go的公司数目有US(329), Japan(79), Brazil(52), India(49), Indonesia(45), China(32), UK(32), Germany(28), Israel(24), France(17), Netherlands(16), Canada (15), Thailand(14), Turkey(14), Spain(12), Poland(11), Australia(9), Russia(9), Iran(8), Sweden(7), Korea(South)(6), Switzerland(6), Ukraine(5)。

参考Go as the emerging language of cloud infrastructure,以及The RedMonk Programming Language Rankings: June 2018,还有GoUsers,以及Success Stories

参考"Go: 90% Perfect, 100% of the time" -bradfitz, 2014

参考Nine years of Go: Go Contributors,社区贡献的代码比例。

我们一起看看这些Go牛逼的特性详细分析每个点虽然不能涵盖所有的点对于常用的Go的特性我们做一次探讨和分析。

Milestones

看看Go做到了哪些Go的重要事件

  • 2020.02, Go1.14发布。可在产品中使用modules,极大提高defer性能抢占式goroutine调度。
  • 2019.09, Go1.13发布。增强了modules新增了环境变量GOPRIVATE和GOSUMDBGOPROXY支持多个。支持了ErrorWraping
  • 2019.02, Go1.12发布支持了TLS1.3改进了modules优化运行时和标准库。
  • 2018.08, Go1.11发布,实验性支持modules,实验性支持WebAssembly
  • 2018.02, Go1.10发布go tool缓存编译编译加速很多细微的改进。
  • 2018.01, Hello, 中国!, 中国站镜像上线,大陆可以访问官网资源。
  • 2017.08, Go1.9发布,支持Type Alias,支持sync.Map,使用场景参考slidestime保持单增避免时间测量问题
  • 2017.02, Go1.8发布显著的性能提升GC延迟降低到了10us到100us,支持HTTP/2 PushHTTP Server支持Shutdownsort.Slice使排序使用更简单。
  • 2016.08, Go1.7发布,支持了ContextContext在K8S和Docker中都有应用新的编译算法减少20-30%的二进制尺寸。
  • 2016.02, Go1.6发布,支持HTTP/2HTTPS时会默认开启HTTP/2,正式支持vendor
  • 2015.08, Go1.5发布完全用Go代替了C代码,完全重新设计和重新实现GC,支持internal的package实验性支持vendorGOMAXPROCS默认为CPU个数。
  • 2014.12, Go1.4发布支持Android从Mecurial迁移到了Git从GoogleCode迁移到了Github: golang/go,大部分runtime的代码从C改成了Gofor支持三种迭代写法。
  • 2014.06, Go1.3发布支持了FreeBSDPlan9Solaris等系统。
  • 2013.12, Go1.2发布,新增收集覆盖率工具coverage,限制了最高线程数ThreadLimit
  • 2013.05, Go1.1发布,主要是包含性能优化,新增Data Race Detector等。
  • 2012.03, Go1.0发布包含了基本的语言元素比如rune、error、map标准库包括bufio、crypto、flag、http、net、os、regexp、runtime、unsafe、url、encoding等。
  • 2009.11, Google宣布要开发一门新语言既要开源又有Python的好处还要有C/C++的性能。GO是BSD的License大部分GO的项目都是BSD或MIT或Apache等商业友好的协议。

Could Not Recover

在C/C++中最苦恼的莫过于上线后发现有野指针或内存越界,导致不可能崩溃的地方崩溃;最无语的是因为很早写的日志打印比如%s把整数当字符串突然某天执行到了崩溃最无奈的是无论因为什么崩溃都导致服务的所有用户收到影响。

如果能有一种方案将指针和内存都管理起来避免用户错误访问和释放这样虽然浪费了一部分的CPU但是可以在快速变化的业务中避免这些头疼的问题。在现代的高级语言中比如Java、Python和JS的异常Go的panic-recover都是这种机制。

毕竟用一些CPU换得快速迭代中的不Crash怎么算都是划得来的。

哪些可以Recover

Go有Defer, Panic, and Recover。其中defer一般用在资源释放或者捕获panic。而panic是中止正常的执行流程执行所有的defer返回调用函数继续panic主动调用panic函数还有些运行时错误都会进入panic过程。最后recover是在panic时获取控制权进入正常的执行逻辑。

注意recover只有在defer函数中才有用在defer的函数调用的函数中recover不起作用如下实例代码不会recover

package main

import "fmt"

func main() {
	f := func() {
		if r := recover(); r != nil {
			fmt.Println(r)
		}
	}

	defer func() {
		f()
	} ()

	panic("ok")
}

执行时依旧会panic结果如下

$ go run t.go
panic: ok

goroutine 1 [running]:
main.main()
	/Users/winlin/temp/t.go:16 +0x6b
exit status 2

有些情况是不可以被捕获程序会自动退出这种都是无法正常recover。当然一般的panic都是能捕获的比如Slice越界、nil指针、除零、写关闭的chan。

下面是Slice越界的例子recover可以捕获到

package main

import (
  "fmt"
)

func main() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println(r)
    }
  }()

  b := []int{0, 1}
  fmt.Println("Hello, playground", b[2])
}

下面是nil指针被引用的例子recover可以捕获到

package main

import (
  "bytes"
  "fmt"
)

func main() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println(r)
    }
  }()

  var b *bytes.Buffer
  fmt.Println("Hello, playground", b.Bytes())
}

下面是除零的例子recover可以捕获到

package main

import (
  "fmt"
)

func main() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println(r)
    }
  }()

  var v int
  fmt.Println("Hello, playground", 1/v)
}

下面是写关闭的chan的例子recover可以捕获到

package main

import (
  "fmt"
)

func main() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println(r)
    }
  }()

  c := make(chan bool)
  close(c)
  c <- true
}

Recover最佳实践

一般recover后会判断是否为err有可能需要处理特殊的error一般也需要打印日志或者告警给一个recover的例子

package main

import (
	"fmt"
)

type Handler interface {
	Filter(err error, r interface{}) error
}

type Logger interface {
	Ef(format string, a ...interface{})
}

// Handle panic by hdr, which filter the error.
// Finally log err with logger.
func HandlePanic(hdr Handler, logger Logger) error {
	return handlePanic(recover(), hdr, logger)
}

type hdrFunc func(err error, r interface{}) error

func (v hdrFunc) Filter(err error, r interface{}) error {
	return v(err, r)
}

type loggerFunc func(format string, a ...interface{})

func (v loggerFunc) Ef(format string, a ...interface{}) {
	v(format, a...)
}

// Handle panic by hdr, which filter the error.
// Finally log err with logger.
func HandlePanicFunc(hdr func(err error, r interface{}) error,
	logger func(format string, a ...interface{}),
) error {
	var f Handler
	if hdr != nil {
		f = hdrFunc(hdr)
	}

	var l Logger
	if logger != nil {
		l = loggerFunc(logger)
	}

	return handlePanic(recover(), f, l)
}

func handlePanic(r interface{}, hdr Handler, logger Logger) error {
	if r != nil {
		err, ok := r.(error)
		if !ok {
			err = fmt.Errorf("r is %v", r)
		}

		if hdr != nil {
			err = hdr.Filter(err, r)
		}

		if err != nil && logger != nil {
			logger.Ef("panic err %+v", err)
		}

		return err
	}

	return nil
}

func main() {
	func() {
		defer HandlePanicFunc(nil, func(format string, a ...interface{}) {
			fmt.Println(fmt.Sprintf(format, a...))
		})

		panic("ok")
	}()

	logger := func(format string, a ...interface{}) {
		fmt.Println(fmt.Sprintf(format, a...))
	}
	func() {
		defer HandlePanicFunc(nil, logger)

		panic("ok")
	}()
}

对于库如果需要启动goroutine如何recover呢

  • 如果不可能出现panic可以不用recover比如tls.go中的一个goroutineerrChannel <- conn.Handshake()
  • 如果可能出现panic也比较明确的可以recover可以用调用用户回调或者让用户设置logger比如http/server.go处理请求的goroutineif err := recover(); err != nil && err != ErrAbortHandler {
  • 如果完全不知道如何处理recover比如一个cache库丢弃数据可能会造成问题那么就应该由用户来启动goroutine返回异常数据和错误用户决定如何recover如何重试。
  • 如果完全知道如何recover比如忽略panic继续跑或者能使用logger打印日志那就按照正常的panic-recover逻辑处理。

哪些不能Recover

下面看看一些情况是无法捕获的,包括(不限于):

  • Thread Limit超过了系统的线程限制详细参考下面的说明。
  • Concurrent Map Writers竞争条件同时写map参考下面的例子。推荐使用标准库的sync.Map解决这个问题。

Map竞争写导致panic的实例代码如下

package main

import (
	"fmt"
	"time"
)

func main() {
	m := map[string]int{}
	p := func() {
		defer func() {
			if r := recover(); r != nil {
				fmt.Println(r)
			}
		}()
		for {
			m["t"] = 0
		}
	}

	go p()
	go p()
	time.Sleep(1 * time.Second)
}

注意:如果编译时加了-race,其他竞争条件也会退出,一般用于死锁检测,但这会导致严重的性能问题,使用需要谨慎。

备注:一般标准库中通过throw抛出的错误都是无法recover的搜索了下Go1.11一共有690个地方有调用throw。

Go1.2引入了能使用的最多线程数限制ThreadLimit如果超过了就panic这个panic是无法recover的。

fatal error: thread exhaustion

runtime stack:
runtime.throw(0x10b60fd, 0x11)
	/usr/local/Cellar/go/1.8.3/libexec/src/runtime/panic.go:596 +0x95
runtime.mstart()
	/usr/local/Cellar/go/1.8.3/libexec/src/runtime/proc.go:1132

默认是1万个物理线程我们可以调用runtimedebug.SetMaxThreads设置最大线程数。

SetMaxThreads sets the maximum number of operating system threads that the Go program can use. If it attempts to use more than this many, the program crashes. SetMaxThreads returns the previous setting. The initial setting is 10,000 threads.

用这个函数设置程序能使用的最大系统线程数如果超过了程序就crash。返回的是之前设置的值默认是1万个线程。

The limit controls the number of operating system threads, not the number of goroutines. A Go program creates a new thread only when a goroutine is ready to run but all the existing threads are blocked in system calls, cgo calls, or are locked to other goroutines due to use of runtime.LockOSThread.

注意限制的并不是goroutine的数目而是使用的系统线程的限制。goroutine启动时并不总是新开系统线程只有当目前所有的物理线程都阻塞在系统调用cgo调用或者显示有调用runtime.LockOSThread时。

SetMaxThreads is useful mainly for limiting the damage done by programs that create an unbounded number of threads. The idea is to take down the program before it takes down the operating system.

这个是最后的防御措施,可以在程序干死系统前把有问题的程序干掉。

举一个简单的例子限制使用10个线程然后用runtime.LockOSThread来绑定goroutine到系统线程可以看到没有创建10个goroutine就退出了runtime也需要使用线程。参考下面的例子Playground: ThreadLimit

package main

import (
  "fmt"
  "runtime"
  "runtime/debug"
  "sync"
  "time"
)

func main() {
  nv := 10
  ov := debug.SetMaxThreads(nv)
  fmt.Println(fmt.Sprintf("Change max threads %d=>%d", ov, nv))

  var wg sync.WaitGroup
  c := make(chan bool, 0)
  for i := 0; i < 10; i++ {
    fmt.Println(fmt.Sprintf("Start goroutine #%v", i))

    wg.Add(1)
    go func() {
      c <- true
      defer wg.Done()
      runtime.LockOSThread()
      time.Sleep(10 * time.Second)
      fmt.Println("Goroutine quit")
    }()

    <- c
    fmt.Println(fmt.Sprintf("Start goroutine #%v ok", i))
  }

  fmt.Println("Wait for all goroutines about 10s...")
  wg.Wait()

  fmt.Println("All goroutines done")
}

运行结果如下:

Change max threads 10000=>10
Start goroutine #0
Start goroutine #0 ok
......
Start goroutine #6
Start goroutine #6 ok
Start goroutine #7
runtime: program exceeds 10-thread limit
fatal error: thread exhaustion

runtime stack:
runtime.throw(0xffdef, 0x11)
	/usr/local/go/src/runtime/panic.go:616 +0x100
runtime.checkmcount()
	/usr/local/go/src/runtime/proc.go:542 +0x100
......
	/usr/local/go/src/runtime/proc.go:1830 +0x40
runtime.startm(0x1040e000, 0x1040e000)
	/usr/local/go/src/runtime/proc.go:2002 +0x180

从这次运行可以看出限制可用的物理线程为10个其中系统占用了3个物理线程user-level可运行7个线程开启第8个线程时就崩溃了。

注意这个运行结果在不同的go版本是不同的比如Go1.8有时候启动4到5个goroutine就会崩溃。

而且加recover也无法恢复参考下面的实例代码。可见这个机制是最后的防御不能突破的底线。我们在线上服务时曾经因为block的goroutine过多导致触发了这个机制。

package main

import (
  "fmt"
  "runtime"
  "runtime/debug"
  "sync"
  "time"
)

func main() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println("main recover is", r)
    }
  } ()

  nv := 10
  ov := debug.SetMaxThreads(nv)
  fmt.Println(fmt.Sprintf("Change max threads %d=>%d", ov, nv))

  var wg sync.WaitGroup
  c := make(chan bool, 0)
  for i := 0; i < 10; i++ {
    fmt.Println(fmt.Sprintf("Start goroutine #%v", i))

    wg.Add(1)
    go func() {
      c <- true

      defer func() {
        if r := recover(); r != nil {
          fmt.Println("main recover is", r)
        }
      } ()

      defer wg.Done()
      runtime.LockOSThread()
      time.Sleep(10 * time.Second)
      fmt.Println("Goroutine quit")
    }()

    <- c
    fmt.Println(fmt.Sprintf("Start goroutine #%v ok", i))
  }

  fmt.Println("Wait for all goroutines about 10s...")
  wg.Wait()

  fmt.Println("All goroutines done")
}

如何避免程序超过线程限制被干掉一般可能阻塞在system call那么什么时候会阻塞还有GOMAXPROCS又有什么作用呢?

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package's GOMAXPROCS function queries and changes the limit.

GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting. If n < 1, it does not change the current setting. The number of logical CPUs on the local machine can be queried with NumCPU. This call will go away when the scheduler improves.

可见GOMAXPROCS只是设置user-level并行执行的线程数也就是真正执行的线程数 。实际上如果物理线程阻塞在system calls实际上会开启更多的物理线程。关于这个参数的说明这个文章Number of threads used by goroutine解释得很清楚:

There is no direct correlation. Threads used by your app may be less than, equal to or more than 10.

So if your application does not start any new goroutines, threads count will be less than 10.

If your app starts many goroutines (>10) where none is blocking (e.g. in system calls), 10 operating system threads will execute your goroutines simultaneously.

If your app starts many goroutines where many (>10) are blocked in system calls, more than 10 OS threads will be spawned (but only at most 10 will be executing user-level Go code).

设置GOMAXPROCS为10如果开启的goroutine小于10个那么物理线程也小于10个。如果有很多goroutines但是没有阻塞在system calls那么只有10个线程会并行执行。如果有很多goroutines同时超过10个阻塞在system calls那么超过10个物理线程会被创建但是只有10个活跃的线程执行user-level代码。

那么什么时候会阻塞在system blocking呢这个例子Why does it not create many threads when many goroutines are blocked in writing解释很清楚虽然设置了GOMAXPROCS为1但是实际上还是开启了12个线程每个goroutine一个物理线程具体执行下面的代码Writing Large Block

package main

import (
  "io/ioutil"
  "os"
  "runtime"
  "strconv"
  "sync"
)

func main() {
  runtime.GOMAXPROCS(1)
  data := make([]byte, 128*1024*1024)

  var wg sync.WaitGroup
  for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(n int) {
      defer wg.Done()
      for {
        ioutil.WriteFile("testxxx"+strconv.Itoa(n), []byte(data), os.ModePerm)
      }
    }(i)
  }

  wg.Wait()
}

运行结果如下:

Mac chengli.ycl$ time go run t.go
real	1m44.679s
user	0m0.230s
sys	0m53.474s

虽然GOMAXPROCS设置为1实际上创建了12个物理线程。

有大量的时间是在sys上面也就是system calls。

So I think the syscalls were exiting too quickly in your original test to show the effect you were expecting.

Effective Go中的解释:

Goroutines are multiplexed onto multiple OS threads so if one should block, such as while waiting for I/O, others continue to run. Their design hides many of the complexities of thread creation and management.

由此可见如果程序出现因为超过线程限制而崩溃那么可以在出现瓶颈时用linux工具查看系统调用的统计看哪些系统调用导致创建了过多的线程。

Errors

错误处理是现实中经常碰到的、难以处理好的问题,下面会从下面几个方面探讨错误处理:

错误和异常

我们总会遇到非预期的非正常情况有一种是符合预期的比如函数返回error并处理这种叫做可以预见到的错误还有一种是预见不到的比如除零、空指针、数组越界等叫做panicpanic的处理主要参考Defer, Panic, and Recover

错误处理的模型一般有两种一般是错误码模型比如C/C++和Go还有异常模型比如Java和C#。Go没有选择异常模型因为错误码比异常更有优势参考文章Cleaner, more elegant, and wrong 以及Cleaner, more elegant, and harder to recognize。看下面的代码:

try {
  AccessDatabase accessDb = new AccessDatabase();
  accessDb.GenerateDatabase();
} catch (Exception e) {
  // Inspect caught exception
}

public void GenerateDatabase()
{
  CreatePhysicalDatabase();
  CreateTables();
  CreateIndexes();
}

这段代码的错误处理有很多问题,比如如果CreateIndexes抛出异常,会导致数据库和表不会删除,造成脏数据。从代码编写者和维护者的角度看这两个模型,会比较清楚:

Really Easy Hard Really Hard
Writing bad error-code-based code
Writing bad exception-based code
Writing good
error-code-based code
Writing good
exception-based code

错误处理不容易做好,要说容易那说明做错了;要把错误处理写对了,基于错误码模型虽然很难,但比异常模型简单。

Really Easy Hard Really Hard
Recognizing that error-code-based
code is badly-written
Recognizing the difference
between bad error-code-based
code and not-bad
error-code-based code.
Recognizing that
error-code-base code
is not badly-written
Recognizing that
exception-based code
is badly-written
Recognizing that
exception-based code
is not badly-written
Recognizing
the difference between
bad exception-based code
and
not-bad exception-based code

如果使用错误码模型,非常容易就能看出错误处理没有写对,也能很容易知道做得好不好;要知道是否做得非常好,错误码模型也不太容易。 如果使用异常模型,无论做的好不好都很难知道,而且也很难知道怎么做好。

Errors in Go

Go官方的error介绍简单一句话就是返回错误对象的方式参考Error handling and Go解释了error是什么如何判断具体的错误显式返回错误的好处。文中举的例子就是打开文件错误

func Open(name string) (file *File, err error)

Go可以返回多个值最后一个一般是error我们需要检查和处理这个错误这就是Go的错误处理的官方介绍

if err := Open("src.txt"); err != nil {
    // Handle err
}

看起来非常简单的错误处理有什么难的呢骚等在Go2的草案中提到的三个点Error HandlingError ValuesGenerics泛型两个点都是错误处理的这说明了Go1中对于错误是有改进的地方。

再详细看下Go2的草案错误处理Error Handling主要描述了发生错误时的重复代码以及不能便捷处理错误的情况。比如草案中举的这个例子No Error Handling: CopyFile没有做任何错误处理

package main

import (
  "fmt"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, _ := os.Open(src)
  defer r.Close()

  w, _ := os.Create(dst)
  io.Copy(w, r)
  w.Close()

  return nil
}

func main() {
  fmt.Println(CopyFile("src.txt", "dst.txt"))
}

还有草案中这个例子Not Nice and still Wrong: CopyFile错误处理是特别啰嗦而且比较明显有问题

package main

import (
  "fmt"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, err := os.Open(src)
  if err != nil {
    return err
  }
  defer r.Close()

  w, err := os.Create(dst)
  if err != nil {
    return err
  }
  defer w.Close()

  if _, err := io.Copy(w, r); err != nil {
    return err
  }
  if err := w.Close(); err != nil {
    return err
  }
  return nil
}

func main() {
  fmt.Println(CopyFile("src.txt", "dst.txt"))
}

io.Copyw.Close出现错误时目标文件实际上是有问题那应该需要删除dst文件的。而且需要给出错误时的信息比如是哪个文件不能直接返回err。所以Go中正确的错误处理应该是这个例子Good: CopyFile虽然啰嗦繁琐不简洁

package main

import (
  "fmt"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, err := os.Open(src)
  if err != nil {
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }
  defer r.Close()

  w, err := os.Create(dst)
  if err != nil {
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }

  if _, err := io.Copy(w, r); err != nil {
    w.Close()
    os.Remove(dst)
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }

  if err := w.Close(); err != nil {
    os.Remove(dst)
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }
  return nil
}

func main() {
  fmt.Println(CopyFile("src.txt", "dst.txt"))
}

具体应该如何简洁的处理错误,可以读Error Handling大致是引入关键字handle和check由于本文重点侧重Go1如何错误处理就不展开分享了。

明显上面每次都返回的fmt.Errorf信息也是不够的所以Go2还对于错误的值有提案参考Error Values。大规模程序应该面向错误编程和测试同时错误应该包含足够的信息。Go1中判断error具体是什么错误有几种办法

  • 直接比较,比如返回的是io.EOF这个全局变量,那么可以直接比较是否是这个错误。
  • 可以用类型转换type或switch尝试来转换成具体的错误类型看是哪种错误。
  • 提供某些函数来判断是否是某个错误,比如os.IsNotExist判断是否是指定错误。
  • 当多个错误被糅合到一起时,只能用error.Error()返回的字符串匹配,看是否是某个错误。

在复杂程序中有用的错误需要包含调用链的信息。例如考虑一次数据库写可能调用了RPCRPC调用了域名解析最终是没有权限读/etc/resolve.conf文件,那么给出下面的调用链会非常有用:

write users database: call myserver.Method: \
    dial myserver:3333: open /etc/resolv.conf: permission denied

Errors Solutions

由于Go1的错误值没有完整的解决这个问题才导致出现非常多的错误处理的库比如

  • 2017, 12, upspin.io/errors带逻辑调用堆栈的错误库而不是执行的堆栈引入了errors.Iserrors.Aserrors.Match
  • 2015.12, github.com/pkg/errors带堆栈的错误引入了%+v来格式化错误的额外信息比如堆栈。
  • 2014.10, github.com/hashicorp/errwrap可以wrap多个错误引入了错误树提供Walk函数遍历所有的错误。
  • 2014.2, github.com/juju/errgoWrap时可以选择是否隐藏底层错误。和pkg/errors的Cause返回最底层的错误不同它只反馈错误链的下一个错误。
  • 2013.7, github.com/spacemonkeygo/errors是来源于一个大型Python项目有错误的hierarchies自动记录日志和堆栈还可以带额外的信息。打印错误的消息比较固定不能自己定义。
  • 2019.09Go1.13标准库扩展了error支持了Unwrap、As和Is但没有支持堆栈信息。

Go1.13改进了errors参考如下实例代码

package main

import (
	"errors"
	"fmt"
	"io"
)

func foo() error {
	return fmt.Errorf("read err: %w", io.EOF)
}

func bar() error {
	if err := foo(); err != nil {
		return fmt.Errorf("foo err: %w", err)
	}
	return nil
}

func main() {
	if err := bar(); err != nil {
		fmt.Printf("err: %+v\n", err)
		fmt.Printf("unwrap: %+v\n", errors.Unwrap(err))
		fmt.Printf("unwrap of unwrap: %+v\n", errors.Unwrap(errors.Unwrap(err)))
		fmt.Printf("err is io.EOF? %v\n", errors.Is(err, io.EOF))
	}
}

运行结果如下:

err: foo err: read err: EOF
unwrap: read err: EOF
unwrap of unwrap: EOF
err is io.EOF? true

从上面的例子可以看出:

  • 没有堆栈信息主要是想通过Wrap的日志来标识堆栈如果全部Wrap一层和堆栈差不多不过对于没有Wrap的错误还是无法知道调用堆栈。
  • Unwrap只会展开第一个嵌套的error如果错误有多层嵌套取不到最里面的那个error需要多次Unwrap才行。
  • errors.Is能判断出是否是最里面的那个error。

另外错误处理往往和log是容易混为一谈的因为遇到错误一般会打日志特别是在C/C++中返回错误码一般都会打日志记录下有时候还会记录一个全局的错误码比如linux的errno而这种习惯造成了error和log混淆造成比较大的困扰。考虑以前写了一个C++的服务器,出现错误时会在每一层打印日志,所以就会形成堆栈式的错误日志,便于排查问题,如果只有一个错误,不知道调用上下文,排查会很困难:

avc decode avc_packet_type failed. ret=3001
Codec parse video failed, ret=3001
origin hub error, ret=3001

这种比只打印一条日志origin hub error, ret=3001要好,但是还不够好:

  1. 和Go的错误一样比较啰嗦有重复的信息。如果能提供堆栈信息可以省去很多需要手动写的信息。
  2. 对于应用程序可以打日志但是对于库信息都应该包含在error中不应该直接打印日志。如果底层的库都要打印日志那会导致底层库都要依赖日志库这是很多库都有日志打印函数供调用者重写。
  3. 对于多线程看不到线程信息或者看不到业务层ID的信息。对于服务器来说有时候需要知道这个错误是哪个连接的从而查询这个连接之前的上下文信息。

改进后的错误日志变成了在底层返回而不在底层打印在调用层打印有调用链和堆栈有线程切换的ID信息也有文件的行数

Error processing video, code=3001 : origin hub : codec parser : avc decoder
[100] video_avc_demux() at [srs_kernel_codec.cpp:676]
[100] on_video() at [srs_app_source.cpp:1076]
[101] on_video_imp() at [srs_app_source:2357]

从Go2的描述来说实际上这个错误处理也还没有考虑完备。从实际开发来说已经比较实用了。

总结下Go的error错误处理应该注意的点

  1. 凡是有返回错误码的函数,必须显式的处理错误,如果要忽略错误,也应该显式的忽略和写注释。
  2. 错误必须带丰富的错误信息,比如堆栈,发生错误时的参数,调用链给的描述等等。特别要强调变量,我看过太多日志描述了一对常量,比如"Verify the nonce, timestamp and token of specified appid failed"而这个消息一般会提到工单中然后就是再问用户哪个session或request甚至时间点这么一大堆常量有啥用呢关键是变量关键是变量呐。
  3. 尽量避免重复的信息,提高错误处理的开发体验,糟糕的体验会导致无效的错误处理代码比如拷贝和漏掉关键信息。
  4. 分离错误和日志,发生错误时返回带完整信息的错误,在调用的顶层决定是将错误用日志打印,还是发送到监控系统,还是转换错误,或者忽略。

Best Practice

推荐用github.com/pkg/errors这个错误处理的库基本上是够用的参考Refine: CopyFile可以看到CopyFile中低级重复的代码已经比较少了

package main

import (
  "fmt"
  "github.com/pkg/errors"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, err := os.Open(src)
  if err != nil {
    return errors.Wrap(err, "open source")
  }
  defer r.Close()

  w, err := os.Create(dst)
  if err != nil {
    return errors.Wrap(err, "create dest")
  }

  nn, err := io.Copy(w, r)
  if err != nil {
    w.Close()
    os.Remove(dst)
    return errors.Wrap(err, "copy body")
  }

  if err := w.Close(); err != nil {
    os.Remove(dst)
    return errors.Wrapf(err, "close dest, nn=%v", nn)
  }

  return nil
}

func LoadSystem() error {
  src, dst := "src.txt", "dst.txt"
  if err := CopyFile(src, dst); err != nil {
    return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))
  }

  // Do other jobs.

  return nil
}

func main() {
  if err := LoadSystem(); err != nil {
    fmt.Printf("err %+v\n", err)
  }
}

改写的函数中,用errors.Wraperrors.Wrapf代替了fmt.Errorf我们不记录src和dst的值因为在上层会记录这个值参考下面的代码而只记录我们这个函数产生的数据比如nn

import "github.com/pkg/errors"

func LoadSystem() error {
	src, dst := "src.txt", "dst.txt"
	if err := CopyFile(src, dst); err != nil {
		return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))
	}

	// Do other jobs.

	return nil
}

在这个上层函数中,我们用的是errors.WithMessage添加了这一层的错误信息,包括srcdst,所以CopyFile里面就不用重复记录这两个数据了。同时我们也没有打印日志,只是返回了带完整信息的错误。

func main() {
	if err := LoadSystem(); err != nil {
		fmt.Printf("err %+v\n", err)
	}
}

在顶层调用时,我们拿到错误,可以决定是打印还是忽略还是送监控系统。

比如我们在调用层打印错误,使用%+v打印详细的错误,有完整的信息:

err open src.txt: no such file or directory
open source
main.CopyFile
	/Users/winlin/t.go:13
main.LoadSystem
	/Users/winlin/t.go:39
main.main
	/Users/winlin/t.go:49
runtime.main
	/usr/local/Cellar/go/1.8.3/libexec/src/runtime/proc.go:185
runtime.goexit
	/usr/local/Cellar/go/1.8.3/libexec/src/runtime/asm_amd64.s:2197
load src=src.txt, dst=dst.txt

但是这个库也有些小毛病:

  1. CopyFile中还是有可能会有重复的信息还是Go2的handlecheck方案是最终解决。
  2. 有时候需要用户调用Wrap,有时候是调用WithMessage(不需要加堆栈时),这个真是非常不好用的地方(这个我们已经修改了库可以全部使用Wrap不用WithMessage会去掉重复的堆栈)。

Logger

一直在码代码,对日志的理解总是不断在变,大致分为几个阶段:

  • 日志是给人看的,是用来查问题的。出现问题后根据某些条件,去查不同进程或服务的日志。日志的关键是不能漏掉信息,漏了关键日志,可能就断了关键的线索。
  • 日志必须要被关联起来上下文的日志比单个日志更重要。长连接需要根据会话关联日志不同业务模型有不同的上下文比如服务器管理把服务器作为关键信息查询这个服务器的相关日志全链路跨机器和服务的日志跟踪需要定义可追踪的逻辑ID。
  • 海量日志是给机器看的,是结构化的,能主动报告问题,能从日志中分析潜在的问题。日志的关键是要被不同消费者消费,要输出不同主题的日志,不同的粒度的日志。日志可以用于排查问题,可以用于告警,可以用于分析业务情况。

Note: 推荐阅读Kafka对于Log的定义广义日志是可以理解的消息The Log: What every software engineer should know about real-time data's unifying abstraction

完善信息查问题

考虑一个服务,处理不同的连接的请求:

package main

import (
	"context"
	"fmt"
	"log"
	"math/rand"
	"os"
	"time"
)

type Connection struct {
	url    string
	logger *log.Logger
}

func (v *Connection) Process(ctx context.Context) {
	go checkRequest(ctx, v.url)

	duration := time.Duration(rand.Int()%1500) * time.Millisecond
	time.Sleep(duration)
	v.logger.Println("Process connection ok")
}

func checkRequest(ctx context.Context, url string) {
	duration := time.Duration(rand.Int()%1500) * time.Millisecond
	time.Sleep(duration)
	logger.Println("Check request ok")
}

var logger *log.Logger

func main() {
	ctx := context.Background()

	rand.Seed(time.Now().UnixNano())
	logger = log.New(os.Stdout, "", log.LstdFlags)

	for i := 0; i < 5; i++ {
		go func(url string) {
			connecton := &Connection{}
			connecton.url = url
			connecton.logger = logger
			connecton.Process(ctx)
		}(fmt.Sprintf("url #%v", i))
	}

	time.Sleep(3 * time.Second)
}

这个日志的主要问题,就是有了和没有差不多,啥也看不出来,常量太多变量太少,缺失了太多的信息。看起来这是个简单问题,却经常容易犯这种问题,需要我们在打印每个日志时,需要思考这个日志比较完善的信息是什么。上面程序输出的日志如下:

2019/11/21 17:08:04 Check request ok
2019/11/21 17:08:04 Check request ok
2019/11/21 17:08:04 Check request ok
2019/11/21 17:08:04 Process connection ok
2019/11/21 17:08:05 Process connection ok
2019/11/21 17:08:05 Check request ok
2019/11/21 17:08:05 Process connection ok
2019/11/21 17:08:05 Check request ok
2019/11/21 17:08:05 Process connection ok
2019/11/21 17:08:05 Process connection ok

如果完善下上下文信息,代码可以改成这样:

type Connection struct {
	url    string
	logger *log.Logger
}

func (v *Connection) Process(ctx context.Context) {
	go checkRequest(ctx, v.url)

	duration := time.Duration(rand.Int()%1500) * time.Millisecond
	time.Sleep(duration)
	v.logger.Println(fmt.Sprintf("Process connection ok, url=%v, duration=%v", v.url, duration))
}

func checkRequest(ctx context.Context, url string) {
	duration := time.Duration(rand.Int()%1500) * time.Millisecond
	time.Sleep(duration)
	logger.Println(fmt.Sprintf("Check request ok, url=%v, duration=%v", url, duration))
}

输出的日志如下:

2019/11/21 17:11:35 Check request ok, url=url #3, duration=32ms
2019/11/21 17:11:35 Check request ok, url=url #0, duration=226ms
2019/11/21 17:11:35 Process connection ok, url=url #0, duration=255ms
2019/11/21 17:11:35 Check request ok, url=url #4, duration=396ms
2019/11/21 17:11:35 Check request ok, url=url #2, duration=449ms
2019/11/21 17:11:35 Process connection ok, url=url #2, duration=780ms
2019/11/21 17:11:35 Check request ok, url=url #1, duration=1.01s
2019/11/21 17:11:36 Process connection ok, url=url #4, duration=1.099s
2019/11/21 17:11:36 Process connection ok, url=url #3, duration=1.207s
2019/11/21 17:11:36 Process connection ok, url=url #1, duration=1.257s

上下文关联

完善日志信息后,对于服务器特有的一个问题,就是如何关联上下文,常见的上下文包括:

  • 如果是短连接,一条日志就能描述,那可能要将多个服务的日志关联起来,将全链路的日志作为上下文。
  • 如果是长连接一般长连接一定会有定时信息比如每隔5秒输出这个链接的码率和包数这样每个链接就无法使用一条日志描述了链接本身就是一个上下文。
  • 进程内的逻辑上下文,比如代理的上下游就是一个上下文,合并回源,故障上下文,客户端重试等。

以上面的代码为例可以用请求URL来作为上下文


package main

import (
	"context"
	"fmt"
	"log"
	"math/rand"
	"os"
	"time"
)

type Connection struct {
	url    string
	logger *log.Logger
}

func (v *Connection) Process(ctx context.Context) {
	go checkRequest(ctx, v.url)

	duration := time.Duration(rand.Int()%1500) * time.Millisecond
	time.Sleep(duration)
	v.logger.Println(fmt.Sprintf("Process connection ok, duration=%v", duration))
}

func checkRequest(ctx context.Context, url string) {
	duration := time.Duration(rand.Int()%1500) * time.Millisecond
	time.Sleep(duration)
	logger.Println(fmt.Sprintf("Check request ok, url=%v, duration=%v", url, duration))
}

var logger *log.Logger

func main() {
	ctx := context.Background()

	rand.Seed(time.Now().UnixNano())
	logger = log.New(os.Stdout, "", log.LstdFlags)

	for i := 0; i < 5; i++ {
		go func(url string) {
			connecton := &Connection{}
			connecton.url = url
			connecton.logger = log.New(os.Stdout, fmt.Sprintf("[CONN %v] ", url), log.LstdFlags)
			connecton.Process(ctx)
		}(fmt.Sprintf("url #%v", i))
	}

	time.Sleep(3 * time.Second)
}

运行结果如下所示:

[CONN url #2] 2019/11/21 17:19:28 Process connection ok, duration=39ms
2019/11/21 17:19:28 Check request ok, url=url #0, duration=149ms
2019/11/21 17:19:28 Check request ok, url=url #1, duration=255ms
[CONN url #3] 2019/11/21 17:19:28 Process connection ok, duration=409ms
2019/11/21 17:19:28 Check request ok, url=url #2, duration=408ms
[CONN url #1] 2019/11/21 17:19:29 Process connection ok, duration=594ms
2019/11/21 17:19:29 Check request ok, url=url #4, duration=615ms
[CONN url #0] 2019/11/21 17:19:29 Process connection ok, duration=727ms
2019/11/21 17:19:29 Check request ok, url=url #3, duration=1.105s
[CONN url #4] 2019/11/21 17:19:29 Process connection ok, duration=1.289s

如果需要查连接2的日志可以grep这个url #2关键字:

Mac:gogogo chengli.ycl$ grep 'url #2' t.log
[CONN url #2] 2019/11/21 17:21:43 Process connection ok, duration=682ms
2019/11/21 17:21:43 Check request ok, url=url #2, duration=998ms

燃鹅,还是发现有不少问题:

  • 如何实现隐式标识,调用时如何简单些,不用没打一条日志都需要传一堆参数?
  • 一般logger是公共函数或者是每个类一个logger而上下文的生命周期会比logger长比如checkRequest是个全局函数标识信息必须依靠人打印这往往是不可行的。
  • 如何实现日志的logrotate(切割和轮转),如何收集多个服务器日志。

解决办法包括:

  • Context的WithValue来将上下文相关的ID保存在打印日志时将ID取出来。
  • 如果有业务特征比如可以取SessionID的hash的前8个字符形成ID虽然容易碰撞但是在一定范围内不容易碰撞。
  • 可以变成json格式的日志这样可以将level、id、tag、file、err都变成可以程序分析的数据送到SLS中处理。
  • 对于切割和轮转,推荐使用lumberjack这个库程序的logger只要提供SetOutput(io.Writer)将日志送给它处理就可以了。

当然这要求函数传参时需要带context.Context一般在自己的应用程序中可以要求这么做凡是打日志的地方要带context。对于库一般可以不打日志而返回带堆栈的复杂错误的方式参考Errors错误处理部分。

Interfaces

Go在类型和接口上的思考是

  • Go类型系统并不是一般意义的OO并不支持虚函数。
  • Go的接口是隐含实现更灵活更便于适配和替换。
  • Go支持的是组合、小接口、组合+小接口。
  • 接口设计应该考虑正交性,组合更利于正交性。

Type System

Go的类型系统是比较容易和C++/Java混淆的特别是习惯于类体系和虚函数的思路后很容易想在Go走这个路子可惜是走不通的。而interface因为太过于简单而且和C++/Java中的概念差异不是特别明显所以这个章节专门分析Go的类型系统。

先看一个典型的问题Is it possible to call overridden method from parent struct in golang?,代码如下所示:

package main

import (
  "fmt"
)

type A struct {
}

func (a *A) Foo() {
  fmt.Println("A.Foo()")
}

func (a *A) Bar() {
  a.Foo()
}

type B struct {
  A
}

func (b *B) Foo() {
  fmt.Println("B.Foo()")
}

func main() {
  b := B{A: A{}}
  b.Bar()
}

本质上它是一个模板方法模式(TemplateMethodPattern)A的Bar调用了虚函数Foo期待子类重写虚函数Foo这是典型的C++/Java解决问题的思路。

我们借用模板方法模式(TemplateMethodPattern)中的例子,考虑实现一个跨平台编译器,提供给用户使用的函数是crossCompile,而这个函数调用了两个模板方法collectSourcecompileToTarget

public abstract class CrossCompiler {
  public final void crossCompile() {
    collectSource();
    compileToTarget();
  }
  //Template methods
  protected abstract void collectSource();
  protected abstract void compileToTarget();
}

C++版不用OOAD思维参考C++: CrossCompiler use StateMachine代码如下所示

// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>

void beforeCompile() {
  printf("Before compile\n");
}

void afterCompile() {
  printf("After compile\n");
}

void collectSource(bool isIPhone) {
  if (isIPhone) {
    printf("IPhone: Collect source\n");
  } else {
        printf("Android: Collect source\n");
    }
}

void compileToTarget(bool isIPhone) {
  if (isIPhone) {
    printf("IPhone: Compile to target\n");
  } else {
        printf("Android: Compile to target\n");
    }
}

void IDEBuild(bool isIPhone) {
  beforeCompile();

  collectSource(isIPhone);
  compileToTarget(isIPhone);

  afterCompile();
}

int main(int argc, char** argv) {
  IDEBuild(true);
  //IDEBuild(false);
  return 0;
}

C++版本使用OOAD思维可以参考C++: CrossCompiler代码如下所示

// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>

class CrossCompiler {
public:
  void crossCompile() {
    beforeCompile();

    collectSource();
    compileToTarget();

    afterCompile();
  }
private:
  void beforeCompile() {
    printf("Before compile\n");
  }
  void afterCompile() {
    printf("After compile\n");
  }
// Template methods.
public:
  virtual void collectSource() = 0;
  virtual void compileToTarget() = 0;
};

class IPhoneCompiler : public CrossCompiler {
public:
  void collectSource() {
    printf("IPhone: Collect source\n");
  }
  void compileToTarget() {
    printf("IPhone: Compile to target\n");
  }
};

class AndroidCompiler : public CrossCompiler {
public:
  void collectSource() {
      printf("Android: Collect source\n");
  }
  void compileToTarget() {
      printf("Android: Compile to target\n");
  }
};

void IDEBuild(CrossCompiler* compiler) {
  compiler->crossCompile();
}

int main(int argc, char** argv) {
  IDEBuild(new IPhoneCompiler());
  //IDEBuild(new AndroidCompiler());
  return 0;
}

我们可以针对不同的平台实现这个编译器比如Android和iPhone

public class IPhoneCompiler extends CrossCompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //iphone specific compilation
  }
}

public class AndroidCompiler extends CrossCompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //android specific compilation
  }
}

在C++/Java中能够完美的工作但是在Go中使用结构体嵌套只能这么实现让IPhoneCompiler和AndroidCompiler内嵌CrossCompiler参考Go: TemplateMethod代码如下所示

package main

import (
  "fmt"
)

type CrossCompiler struct {
}

func (v CrossCompiler) crossCompile() {
  v.collectSource()
  v.compileToTarget()
}

func (v CrossCompiler) collectSource() {
  fmt.Println("CrossCompiler.collectSource")
}

func (v CrossCompiler) compileToTarget() {
  fmt.Println("CrossCompiler.compileToTarget")
}

type IPhoneCompiler struct {
  CrossCompiler
}

func (v IPhoneCompiler) collectSource() {
  fmt.Println("IPhoneCompiler.collectSource")
}

func (v IPhoneCompiler) compileToTarget() {
  fmt.Println("IPhoneCompiler.compileToTarget")
}

type AndroidCompiler struct {
  CrossCompiler
}

func (v AndroidCompiler) collectSource() {
  fmt.Println("AndroidCompiler.collectSource")
}

func (v AndroidCompiler) compileToTarget() {
  fmt.Println("AndroidCompiler.compileToTarget")
}

func main() {
  iPhone := IPhoneCompiler{}
  iPhone.crossCompile()
}

执行结果却让人手足无措:

# Expect
IPhoneCompiler.collectSource
IPhoneCompiler.compileToTarget

# Output
CrossCompiler.collectSource
CrossCompiler.compileToTarget

Go并没有支持类继承体系和多态Go是面向对象却不是一般所理解的那种面向对象用老子的话说“道可道非常道”。

实际上在OOAD中除了类继承之外还有另外一个解决问题的思路就是组合Composition面向对象设计原则中有个很重要的就是The Composite Reuse Principle (CRP)Favor delegation over inheritance as a reuse mechanism,重用机制应该优先使用组合(代理)而不是类继承。类继承会丧失灵活性,而且访问的范围比组合要大;组合有很高的灵活性,另外组合使用另外对象的接口,所以能获得最小的信息。

C++如何使用组合代替继承实现模板方法可以考虑让CrossCompiler使用其他的类提供的服务或者说使用接口比如CrossCompiler依赖于ICompiler

public interface ICompiler {
  //Template methods
  protected abstract void collectSource();
  protected abstract void compileToTarget();
}

public abstract class CrossCompiler {
  public ICompiler compiler;
  public final void crossCompile() {
    compiler.collectSource();
    compiler.compileToTarget();
  }
}

C++版本可以参考C++: CrossCompiler use Composition代码如下所示

// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>

class ICompiler {
// Template methods.
public:
  virtual void collectSource() = 0;
  virtual void compileToTarget() = 0;
};

class CrossCompiler {
public:
  CrossCompiler(ICompiler* compiler) : c(compiler) {
  }
  void crossCompile() {
    beforeCompile();

    c->collectSource();
    c->compileToTarget();

    afterCompile();
  }
private:
  void beforeCompile() {
    printf("Before compile\n");
  }
  void afterCompile() {
    printf("After compile\n");
  }
  ICompiler* c;
};

class IPhoneCompiler : public ICompiler {
public:
  void collectSource() {
    printf("IPhone: Collect source\n");
  }
  void compileToTarget() {
    printf("IPhone: Compile to target\n");
  }
};

class AndroidCompiler : public ICompiler {
public:
  void collectSource() {
      printf("Android: Collect source\n");
  }
  void compileToTarget() {
      printf("Android: Compile to target\n");
  }
};

void IDEBuild(CrossCompiler* compiler) {
  compiler->crossCompile();
}

int main(int argc, char** argv) {
  IDEBuild(new CrossCompiler(new IPhoneCompiler()));
  //IDEBuild(new CrossCompiler(new AndroidCompiler()));
  return 0;
}

我们可以针对不同的平台实现这个ICompiler比如Android和iPhone。这样从继承的类体系变成了更灵活的接口的组合以及对象直接服务的调用

public class IPhoneCompiler implements ICompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //iphone specific compilation
  }
}

public class AndroidCompiler implements ICompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //android specific compilation
  }
}

在Go中推荐用组合和接口小的接口大的对象。这样有利于只获得自己应该获取的信息或者不会获得太多自己不需要的信息和函数参考Clients should not be forced to depend on methods they do not use. Robert C. Martin,以及The bigger the interface, the weaker the abstraction, Rob Pike。关于面向对象的原则在Go中的体现参考Go: SOLID中文版Go: SOLID

先看如何使用Go的思路实现前面的例子跨平台编译器Go Composition: Compiler代码如下所示

package main

import (
  "fmt"
)

type SourceCollector interface {
  collectSource()
}

type TargetCompiler interface {
  compileToTarget()
}

type CrossCompiler struct {
  collector SourceCollector
  compiler  TargetCompiler
}

func (v CrossCompiler) crossCompile() {
  v.collector.collectSource()
  v.compiler.compileToTarget()
}

type IPhoneCompiler struct {
}

func (v IPhoneCompiler) collectSource() {
  fmt.Println("IPhoneCompiler.collectSource")
}

func (v IPhoneCompiler) compileToTarget() {
  fmt.Println("IPhoneCompiler.compileToTarget")
}

type AndroidCompiler struct {
}

func (v AndroidCompiler) collectSource() {
  fmt.Println("AndroidCompiler.collectSource")
}

func (v AndroidCompiler) compileToTarget() {
  fmt.Println("AndroidCompiler.compileToTarget")
}

func main() {
  iPhone := IPhoneCompiler{}
  compiler := CrossCompiler{iPhone, iPhone}
  compiler.crossCompile()
}

这个方案中,将两个模板方法定义成了两个接口,CrossCompiler使用了这两个接口因为本质上C++/Java将它的函数定义为抽象函数意思也是不知道这个函数如何实现。而IPhoneCompilerAndroidCompiler并没有继承关系,而它们两个实现了这两个接口,供CrossCompiler使用;也就是它们之间的关系,从之前的强制绑定,变成了组合。

type SourceCollector interface {
	collectSource()
}

type TargetCompiler interface {
	compileToTarget()
}

type CrossCompiler struct {
	collector SourceCollector
	compiler  TargetCompiler
}

func (v CrossCompiler) crossCompile() {
	v.collector.collectSource()
	v.compiler.compileToTarget()
}

Rob Pike在Go Language: Small and implicit中描述Go的类型和接口第29页说

  • Objects implicitly satisfy interfaces. A type satisfies an interface simply by implementing its methods. There is no "implements" declaration; interfaces are satisfied implicitly. 这种隐式的实现接口实际中还是很灵活的我们在Refector时可以将对象改成接口缩小所依赖的接口时能够不改变其他地方的代码。比如如果一个函数foo(f *os.File),最初依赖于os.File,但实际上可能只是依赖于io.Reader就可以方便做UTest那么可以直接修改成foo(r io.Reader)所有地方都不用修改,特别是这个接口是新增的自定义接口时就更明显。
  • In Go, interfaces are usually small: one or two or even zero methods. 在Go中接口都比较小非常小只有一两个函数但是对象却会比较大会使用很多的接口。这种方式能够以最灵活的方式重用代码而且保持接口的有效性和最小化也就是接口隔离。

隐式实现接口有个很好的作用,就是两个类似的模块实现同样的服务时,可以无缝的提供服务,甚至可以同时提供服务。比如改进现有模块时,比如两个不同的算法。更厉害的时,两个模块创建的私有接口,如果它们签名一样,也是可以互通的,其实签名一样就是一样的接口,无所谓是不是私有的了。这个非常强大,可以允许不同的模块在不同的时刻升级,这对于提供服务的服务器太重要了。

比较被严重误认为是继承的莫过于是Go的内嵌Embeding因为Embeding本质上还是组合不是继承参考Embeding is still composition

Embeding在UTest的Mocking中可以显著减少需要Mock的函数比如Mocking net.Conn如果只需要mock Read和Write两个函数就可以通过内嵌net.Conn来实现这样loopBack也实现了整个net.Conn接口不必每个接口全部写一遍

type loopBack struct {
    net.Conn
    buf bytes.Buffer
}

func (c *loopBack) Read(b []byte) (int, error) {
    return c.buf.Read(b)
}

func (c *loopBack) Write(b []byte) (int, error) {
    return c.buf.Write(b)
}

Embeding只是将内嵌的数据和函数自动全部代理了一遍而已本质上还是使用这个内嵌对象的服务。Outer内嵌了Inner和Outer继承Inner的区别在于内嵌Inner是不知道自己被内嵌调用Inner的函数并不会对Outer有任何影响Outer内嵌Inner只是自动将Inner的数据和方法代理了一遍但是本质上Inner的东西还不是Outer的东西对于继承调用Inner的函数有可能会改变Outer的数据因为Outer继承Inner那么Outer就是Inner二者的依赖是更紧密的。

如果很难理解为何Embeding不是继承本质上是没有区分继承和组合的区别可以参考Composition not inheritanceGo选择组合不选择继承是深思熟虑的决定面向对象的继承、虚函数、多态和类树被过度使用了。类继承树需要前期就设计好而往往系统在演化时发现类继承树需要变更我们无法在前期就精确设计出完美的类继承树Go的接口和组合在接口变更时只需要变更最直接的调用层而没有类子树需要变更。

The designs are nothing like hierarchical, subtype-inherited methods. They are looser (even ad hoc), organic, decoupled, independent, and therefore scalable.

组合比继承有个很关键的优势是正交性orthogonal,详细参考正交性

Orthogonal

真水无香,真的牛逼不用装。——来自网络

软件是一门科学也是艺术换句话说软件是工程。科学的意思是逻辑、数学、二进制比较偏基础的理论都是需要数学的比如C的结构化编程是有论证的那些关键字和逻辑是够用的。实际上Go的GC也是有数学证明的还有一些网络传输算法又比如奠定一个新领域的论文比如Google的论文。艺术的意思是大部分时候都用不到严密的论证有很多种不同的路还需要看自己的品味或者叫偏见特别容易引起口水仗和争论从好的方面说好的软件或代码是能被感觉到很好的。

由于大部分时候软件开发是要靠经验的特别是国内填鸭式教育培养了对于数学的莫名的仇恨“莫名”主要是早就把该忘的不该忘记的都忘记了所以在代码中强调数学会激发起大家心中一种特别的鄙视和怀疑而这种鄙视和怀疑应该是以葱白和畏惧为基础——大部分时候在代码中吹数学都会被认为是装逼。而Orthogonal(正交性)则不择不扣的是个数学术语,是线性代数(就是矩阵那个玩意儿)中用来描述两个向量相关性的,在平面中就是两个线条的垂直。比如下图:

image.png

Vectors A and B are orthogonal to each other.

旁白:妮玛,两个线条垂直能和代码有个毛线关系,八竿子打不着关系吧,请继续吹。

先请看Go关于Orthogonal相关的描述可能还不止这些地方

Composition not inheritance Object-oriented programming provides a powerful insight: that the behavior of data can be generalized independently of the representation of that data. The model works best when the behavior (method set) is fixed, but once you subclass a type and add a method, the behaviors are no longer identical. If instead the set of behaviors is fixed, such as in Go's statically defined interfaces, the uniformity of behavior enables data and programs to be composed uniformly, orthogonally, and safely.

JSON-RPC: a tale of interfaces In an inheritance-oriented language like Java or C++, the obvious path would be to generalize the RPC class, and create JsonRPC and GobRPC subclasses. However, this approach becomes tricky if you want to make a further generalization orthogonal to that hierarchy.

实际上Orthogonal并不是只有Go才提参考Orthogonal Software。实际上很多软件设计都会提正交性比如OOAD里面也有不少地方用这个描述。我们先从实际的例子出发吧关于线程一般Java、Python、C#等语言会定义个线程的类Thread可能包含以下的方法管理线程

var thread = new Thread(thread_main_function);
thread.Start();
thread.Interrupt();
thread.Join();
thread.Stop();

如果把goroutine也看成是Go的线程那么实际上Go并没有提供上面的方法而是提供了几种不同的机制来管理线程

  • go关键字启动goroutine。
  • sync.WaitGroup等待线程退出。
  • chan也可以用来同步比如等goroutine启动或退出或者传递退出信息给goroutine。
  • context也可以用来管理goroutine参考Context
s := make(chan bool, 0)
q := make(chan bool, 0)
go func() {
    s <- true // goroutine started.
    for {
        select {
        case <-q:
            return
        default:
            // do something.
        }
    }
} ()

<- s // wait for goroutine started.
time.Sleep(10)
q <- true // notify goroutine quit.

注意上面只是例子,实际中推荐用Context管理goroutine。

如果把goroutine看成一个向量把sync看成一个向量把chan看成一个向量这些向量都不相关也就是它们是正交的。

再举在Orthogonal Software的例子将对象存储到TEXT或XML文件可以直接写对象的序列化函数

def read_dictionary(file)
  if File.extname(file) == ".xml"
    # read and return definitions in XML from file
  else
    # read and return definitions in text from file
  end
end

这个的坏处包括:

  1. 逻辑代码和序列化代码混合在一起,随处可见序列化代码,非常难以维护。
  2. 如果要新增序列化的机制比如将对象序列化存储到网络就很费劲了。
  3. 假设TEXT要支持JSON格式或者INI格式呢

如果改进下这个例子,将存储分离:

class Dictionary
  def self.instance(file)
    if File.extname(file) == ".xml"
      XMLDictionary.new(file)
    else
      TextDictionary.new(file)
    end
  end
end

class TextDictionary < Dictionary
  def write
    # write text to @file using the @definitions hash
  end
  def read
    # read text from @file and populate the @definitions hash
  end
end

如果把Dictionay看成一个向量把存储方式看成一个向量再把JSON或INI格式看成一个向量他们实际上是可以不相关的。

再看一个例子,考虑上面JSON-RPC: a tale of interfaces的修改,实际上是将序列化的部分,从*gob.Encoder变成了接口ServerCodec然后实现了jsonCodec和gobCodec两种Codec所以RPC和ServerCodec是正交的。非正交的做法就是从RPC继承两个类jsonRPC和gobRPC这样RPC和Codec是耦合的并不是不相关的。

Orthogonal不相关到底有什么好说的

  • 数学中不相关的两个向量可以作为空间的基比如平面上就是x和y轴从向量看就是两个向量这两个不相关的向量x和y可以组合出平面的任意向量平面任一点都可以用x和y表示如果向量不正交有些区域就不能用这两个向量表达有些点就不能表达。这个在接口设计上就是正交的接口能让用户灵活组合出能解决各种问题的调用方式不相关的向量可以张成整个向量空间;同样的如果不正交,有时候就发现自己想要的功能无法通过现有接口实现,必须修改接口的定义。
  • 比如goroutine的例子我们可以用sync或chan达到自己想要的控制goroutine的方式。比如context也是组合了chan、timeout、value等接口提供的一个比较明确的功能库。这些语言级别的正交的元素可以组合成非常多样和丰富的库。比如有时候我们需要等goroutine启动有时候不用有时候甚至不需要管理goroutine有时候需要主动通知goroutine退出有时候我们需要等goroutine出错后处理。
  • 比如序列化TEXT或XML的例子可以将对象的逻辑完全和存储分离避免对象的逻辑中随处可见存储对象的代码维护性可以极大的提升。另外两个向量的耦合还可以理解如果是多个向量的耦合就难以实现比如要将对象序列化为支持注释的JSON先存储到网络有问题再存储为TEXT文件同时如果是程序升级则存储为XML文件这种复杂的逻辑实际上需要很灵活的组合本质上就是空间的多个向量的组合表达出空间的新向量(新功能)。
  • 当对象出现了自己不该有的特性和方法会造成巨大的维护成本。比如如果TEXT和XML机制耦合在一起那么维护TEXT协议时要理解XML的协议改动TEXT时竟然造成XML挂掉了。使用时如果出现自己不用的函数也是一种坏味道比如Copy(src, dst io.ReadWriter)就有问题因为src明显不会用到Write而dst不会用到Read,所以改成Copy(src io.Reader, dst io.Writer)才是合理的。

由此可见Orthogonal是接口设计中非常关键的要素我们需要从概念上考虑接口尽量提供正交的接口和函数。比如io.Readerio.Writerio.Closer是正交的,因为有时候我们需要的新向量是读写那么可以使用io.ReadWriter,这实际上是两个接口的组合。

我们如何才能实现Orthogonal的接口呢特别对于公共库这个非常关键直接决定了我们是否能提供好用的库还是很烂的不知道怎么用的库。有几个建议

  1. 好用的公共库使用者可以通过IDE的提示就知道怎么用不应该提供多个不同的路径实现一个功能会造成很大的困扰。比如Android的通讯录超级多的完全不同的类可以用实际上就是非常难用。
  2. 必须要有完善的文档。完全通过代码就能表达Why和How是不可能的。就算是Go的标准库也是大量的注释如果一个公共库没有文档和注释会非常的难用和维护。
  3. 一定要先写Example一定要提供UTest完全覆盖。没有Example的公共库是不知道接口设计是否合理的没有人有能力直接设计一个合理的库只有从使用者角度分析才能知道什么是合理Example就是使用者角度标准库有大量的Example。UTest也是一种使用不过是内部使用也很必要。

如果上面数学上有不严谨的请原谅我,我数学很渣。

Modules

先把最重要的说了关于modules的最新详细信息可以执行命令go help modules或者查这个长长的手册Go Modules另外modules弄清楚后很好用迁移成本低。

Go Module的好处可以参考Demo

  1. 代码不用必须放GOPATH可以放在任何目录终于不用做软链了。
  2. Module依然可以用vendor如果不需要更新依赖可以不必从远程下载依赖代码同样不必放GOPATH。
  3. 如果在一个仓库可以直接引用会自动识别模块内部的package同样不用链接到GOPATH。

Go最初是使用GOPATH存放依赖的包项目和代码这个GOPATH是公共的目录如果依赖的库的版本不同就杯具了。2016年也就是7年后才支持vendor规范就是将依赖本地化了每个项目都使用自己的vendor文件夹但这样也解决不了冲突的问题具体看下面的分析相反导致各种包管理项目天下混战参考pkg management tools。2017年也就是8年后官方的vendor包管理器dep才确定方案看起来命中注定的TheOne终于尘埃落定。不料2018年也就是9年后又提出比较完整的方案versioningvgo这年Go1.11支持了Modules2019年Go1.12和Go1.13改进了不少Modules内容Go官方文档推出一系列的Part 1 — Using Go ModulesPart 2 — Migrating To Go ModulesPart 3 — Publishing Go Modules终于应该大概齐能明白这次真的确定和肯定了Go Modules是最终方案。

为什么要搞出GOPATH、Vendor和GoModules这么多技术方案本质上是为了创造就业岗位一次创造了indexproxysum三个官网,哈哈哈。当然技术上也是必须要这么做的,简单来说是为了解决古老的DLL Hell问题,也就是依赖管理和版本管理的问题。版本说起来就是几个数字,比如1.2.3,实际上是非常复杂的问题,推荐阅读Semantic Versioning假设定义了良好和清晰的API我们用版本号来管理API的兼容性版本号一般定义为MAJOR.MINOR.PATCHMajor变更时意味着不兼容的API变更Minor是功能变更但是是兼容的Patch是BugFix也是兼容的Major为0时表示API还不稳定。由于Go的包是URL的没有版本号信息最初对于包的版本管理原则是必须一直保持接口兼容

If an old package and a new package have the same import path, the new package must be backwards compatible with the old package.

试想下如果所有我们依赖的包,一直都是接口兼容的,那就没有啥问题,也没有DLL Hell。可惜现实却不是这样如果我们提供过包就知道对于持续维护和更新的包在最初不可能提供一个永远不变的接口变化的接口就是不兼容的了。就算某个接口可以不变还有依赖的包还有依赖的依赖的包还有依赖的依赖的依赖的包以此往复要求世界上所有接口都不变才不会有版本问题这么说起来包管理是个极其难以解决的问题Go花了10年才确定最终方案就是这个原因了下面举例子详细分析这个问题。

备注:标准库也有遇到接口变更的风险,比如Context是Go1.7才引入标准库的,控制程序生命周期,后续有很多接口的第一个参数都是ctx context.Context,比如net.DialContext就是后面加的一个函数,而net.Dial也是调用它。再比如http.Request.WithContext则提供了一个函数将context放在结构体中传递这是因为要再为每个Request的函数新增一个参数不太合适。从context对于标准库的接口的变更可以看得到这里有些不一致性有很多批评的声音比如Context should go away for Go 2就是觉得在标准库中加context作为第一个参数不能理解比如Read(ctx context.Context等。

GOPATH & Vendor

咱们先看GOPATH的方式。Go引入外部的包是URL方式的先在环境变量$GOROOT中搜索,然后在$GOPATH中搜索比如我们使用Errors依赖包github.com/ossrs/go-oryx-lib/errors,代码如下所示:

package main

import (
  "fmt"
  "github.com/ossrs/go-oryx-lib/errors"
)

func main() {
  fmt.Println(errors.New("Hello, playground"))
}

如果我们直接运行会报错,错误信息如下:

prog.go:5:2: cannot find package "github.com/ossrs/go-oryx-lib/errors" in any of:
	/usr/local/go/src/github.com/ossrs/go-oryx-lib/errors (from $GOROOT)
	/go/src/github.com/ossrs/go-oryx-lib/errors (from $GOPATH)

需要先下载这个依赖包go get -d github.com/ossrs/go-oryx-lib/errors然后运行就可以了。下载后放在GOPATH中

Mac $ ls -lh $GOPATH/src/github.com/ossrs/go-oryx-lib/errors
total 72
-rw-r--r--  1 chengli.ycl  staff   1.3K Sep  8 15:35 LICENSE
-rw-r--r--  1 chengli.ycl  staff   2.2K Sep  8 15:35 README.md
-rw-r--r--  1 chengli.ycl  staff   1.0K Sep  8 15:35 bench_test.go
-rw-r--r--  1 chengli.ycl  staff   6.7K Sep  8 15:35 errors.go
-rw-r--r--  1 chengli.ycl  staff   5.4K Sep  8 15:35 example_test.go
-rw-r--r--  1 chengli.ycl  staff   4.7K Sep  8 15:35 stack.go

如果我们依赖的包还依赖于其他的包,那么go get会下载所有依赖的包到GOPATH。这样是下载到公共的GOPATH的可以想到这会造成几个问题

  1. 每次都要从网络下载依赖可能对于美国这个问题不存在但是对于中国要从GITHUB上下载很大的项目是个很麻烦的问题还没有断点续传。
  2. 如果两个项目依赖了GOPATH了项目如果一个更新会导致另外一个项目出现问题。比如新的项目下载了最新的依赖库可能会导致其他项目出问题。
  3. 无法独立管理版本号和升级独立依赖不同的包的版本。比如A项目依赖1.0的库而B项目依赖2.0的库。注意如果A和B都是库的话这个问题还是无解的它们可能会同时被一个项目引用如果A和B是最终的应用是没有问题应用可以用不同的版本它们在自己的目录。

为了解决这些问题引入了vendor在src下面有个vendor目录将依赖的库都下载到这个目录同时会有描述文件说明依赖的版本这样可以实现升级不同库的升级。参考vendor,以及官方的包管理器dep。但是vendor并没有解决所有的问题特别是包的不兼容版本的问题只解决了项目或应用也就是会编译出二进制的项目所依赖库的问题。

咱们把上面的例子用vendor实现先要把项目软链或者挪到GOPATH里面去若没有dep工具可以参考Installation安装然后执行下面的命令来将依赖导入到vendor目录

dep init && dep ensure

这样依赖的文件就会放在vendor下面编译时也不再需要从远程下载了

├── Gopkg.lock
├── Gopkg.toml
├── t.go
└── vendor
    └── github.com
        └── ossrs
            └── go-oryx-lib
                └── errors
                    ├── errors.go
                    └── stack.go

Remark: Vendor也会选择版本也有版本管理但每个包它只会选择一个版本也就是本质上是本地化的GOPATH如果出现钻石依赖和冲突还是无解下面会详细说明。

何为版本冲突?

我们来看GOPATH和Vencor无法解决的一个问题版本依赖问题的一个例子Semantic Import Versioning考虑钻石依赖的情况用户依赖于两个云服务商的SDK而他们可能都依赖于公共的库形成一个钻石形状的依赖用户依赖AWS和Azure而它们都依赖OAuth

image.png

如果公共库package这里是OAuth的导入路径一样比如是github.com/google/oauth但是做了非兼容性变更发布了OAuth-r1和OAuth-r2其中一个云服务商更新了自己的依赖另外一个没有更新就会造成冲突他们依赖的版本不同

image.png

在Go中无论怎么修改都无法支持这种情况除非在package的路径中加入版本语义进去也就是在路径上带上版本信息这就是Go Modules了这和优雅没有关系这实际上是最好的使用体验

image.png

另外做法就是改变包路径,这要求包提供者要每个版本都要使用一个特殊的名字,但使用者也不能分辨这些名字代表的含义,自然也不知道如何选择哪个版本。

先看看Go Modules创造的三大就业岗位index负责索引、proxy负责代理缓存和sum负责签名校验,它们之间的关系在Big Picture中有描述。可见go-get会先从index获取指定package的索引然后从proxy下载数据最后从sum来获取校验信息

image.png

vgo全面实践

还是先跟着官网的三部曲先了解下modules的基本用法后面补充下特别要注意的问题就差不多齐了。首先是Using Go Modules如何使用modules还是用上面的例子代码不用改变只需要执行命令

go mod init private.me/app && go run t.go

Remark和vendor并不相同modules并不需要在GOPATH下面才能创建所以这是非常好的。

执行的结果如下可以看到vgo查询依赖的库下载后解压到了cache并生成了go.mod和go.sum缓存的文件在$GOPATH/pkg下面:

Mac:gogogo chengli.ycl$ go mod init private.me/app && go run t.go
go: creating new go.mod: module private.me/app
go: finding github.com/ossrs/go-oryx-lib v0.0.7
go: downloading github.com/ossrs/go-oryx-lib v0.0.7
go: extracting github.com/ossrs/go-oryx-lib v0.0.7
Hello, playground

Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/ossrs/go-oryx-lib v0.0.7 // indirect

Mac:gogogo chengli.ycl$ cat go.sum
github.com/ossrs/go-oryx-lib v0.0.7 h1:k8ml3ZLsjIMoQEdZdWuy8zkU0w/fbJSyHvT/s9NyeCc=
github.com/ossrs/go-oryx-lib v0.0.7/go.mod h1:i2tH4TZBzAw5h+HwGrNOKvP/nmZgSQz0OEnLLdzcT/8=

Mac:gogogo chengli.ycl$ tree $GOPATH/pkg
/Users/winlin/go/pkg
├── mod
│   ├── cache
│   │   ├── download
│   │   │   ├── github.com
│   │   │   │   └── ossrs
│   │   │   │       └── go-oryx-lib
│   │   │   │           └── @v
│   │   │   │               ├── list
│   │   │   │               ├── v0.0.7.info
│   │   │   │               ├── v0.0.7.zip
│   │   │   └── sumdb
│   │   │       └── sum.golang.org
│   │   │           ├── lookup
│   │   │           │   └── github.com
│   │   │           │       └── ossrs
│   │   │           │           └── go-oryx-lib@v0.0.7
│   └── github.com
│       └── ossrs
│           └── go-oryx-lib@v0.0.7
│               ├── errors
│               │   ├── errors.go
│               │   └── stack.go
└── sumdb
└── sum.golang.org
└── latest

可以手动升级某个库即go get这个库

Mac:gogogo chengli.ycl$ go get github.com/ossrs/go-oryx-lib
go: finding github.com/ossrs/go-oryx-lib v0.0.8
go: downloading github.com/ossrs/go-oryx-lib v0.0.8
go: extracting github.com/ossrs/go-oryx-lib v0.0.8

Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/ossrs/go-oryx-lib v0.0.8

升级某个包到指定版本,可以带上版本号,例如go get github.com/ossrs/go-oryx-lib@v0.0.8。当然也可以降级比如现在是v0.0.8,可以go get github.com/ossrs/go-oryx-lib@v0.0.7降到v0.0.7版本。也可以升级所有依赖的包,执行go get -u命令就可以。查看依赖的包和版本,以及依赖的依赖的包和版本,可以执行go list -m all命令。查看指定的包有哪些版本,可以用go list -m -versions github.com/ossrs/go-oryx-lib命令。

Note: 关于vgo如何选择版本可以参考Minimal Version Selection

如果依赖了某个包大版本的多个版本,那么会选择这个大版本最高的那个,比如:

  • 若a依赖v1.0.1b依赖v1.2.3程序依赖a和b时最终使用v1.2.3。
  • 若a依赖v1.0.1d依赖v0.0.7程序依赖a和d时最终使用v1.0.1也就是认为v1是兼容v0的。

比如下面代码,依赖了四个包,而这四个包依赖了某个包的不同版本,分别选择不同的包,执行rm -f go.mod && go mod init private.me/app && go run t.go,可以看到选择了不同的版本,始终选择的是大版本最高的那个(也就是满足要求的最小版本):

package main

import (
	"fmt"
	"github.com/winlinvip/mod_ref_a" // 1.0.1
	"github.com/winlinvip/mod_ref_b" // 1.2.3
	"github.com/winlinvip/mod_ref_c" // 1.0.3
	"github.com/winlinvip/mod_ref_d" // 0.0.7
)

func main() {
	fmt.Println("Hello",
		mod_ref_a.Version(),
		mod_ref_b.Version(),
		mod_ref_c.Version(),
		mod_ref_d.Version(),
	)
}

若包需要升级大版本则需要在路径上加上版本包括本身的go.mod中的路径依赖这个包的go.mod依赖它的代码比如下面的例子同时使用了v1和v2两个版本只用一个也可以

package main

import (
	"fmt"
	"github.com/winlinvip/mod_major_releases"
	v2 "github.com/winlinvip/mod_major_releases/v2"
)

func main() {
	fmt.Println("Hello",
		mod_major_releases.Version(),
		v2.Version2(),
	)
}

运行这个程序后可以看到go.mod中导入了两个包

module private.me/app
go 1.13
require (
        github.com/winlinvip/mod_major_releases v1.0.1
        github.com/winlinvip/mod_major_releases/v2 v2.0.3
)

Remark: 如果需要更新v2的指定版本那么路径中也必须带v2也就是所有v2的路径必须带v2比如go get github.com/winlinvip/mod_major_releases/v2@v2.0.3

而库提供大版本也是一样的,参考mod_major_releases/v2,主要做的事情:

  1. 新建v2的分支git checkout -b v2,比如https://github.com/winlinvip/mod_major_releases/tree/v2
  2. 修改go.mod的描述路径必须带v2比如module github.com/winlinvip/mod_major_releases/v2
  3. 提交后打v2的tag比如git tag v2.0.0分支和tag都要提交到git。

其中go.mod更新如下

module github.com/winlinvip/mod_major_releases/v2
go 1.13

代码更新如下,由于是大版本,所以就变更了函数名称:

package mod_major_releases

func Version2() string {
	return "mmv/2.0.3"
}

Note: 更多信息可以参考Modules: v2,还有Russ Cox: From Repository to Modules介绍了两种方式,常见的就是上面的分支方式的例子,还有一种文件夹方式。

Go Modules特别需要注意的问题

  • 对于公开的package如果go.mod中描述的package和公开的路径不相同比如go.mod是private.me/app,而发布到github.com/winlinvip/app当然其他项目import这个包时会出现错误。对于库也就是希望别人依赖的包go.mod描述的和发布的路径以及package名字都应该保持一致。
  • 如果一个包没有发布任何版本则会取最新的commit和日期格式为v0.0.0-日期-commit号比如v0.0.0-20191028070444-45532e158b41,参考Pseudo Versions。版本号可以从v0.0.x开始,比如v0.0.1或者v0.0.3或者v0.1.0或者v1.0.1之类没有强制要求必须要是1.0开始的发布版本。
  • mod replace在子module无效只在编译的那个top level有效也就是在最终生成binary的go.mod中定义才有效官方的说明是为了让最终生成时控制依赖。例如想要把github.com/pkg/errors重写为github.com/winlinvip/errors这个包,正确做法参考分支replace_errors;若不在主模块(top level)中replace参考replace_in_submodule,只在子模块中定义了replace但会被忽略如果在主模块replace会生效replace_errors,而且在主模块依赖掉子模快依赖的模块也生效replace_deps_of_submodule。不过在子模快中也能replace这个预感到会是个混淆的地方。有一个例子就是fork仓库后修改后自己使用这时候go.mod的package当然也变了参考Migrating Go1.13 ErrorsGo1.13的errors支持了Unwrap接口这样可以拿到root error而pkg/errors使用的则是Cause(err)函数来获取root error而提的PR没有支持pkg/errors不打算支持Go1.13的方式作者建议fork来解决所以就可以使用go mod replace来将fork的url替换pkg/errors。
  • go get并非将每个库都更新后取最新的版本,比如库github.com/winlinvip/mod_minor_versions有v1.0.1、v1.1.2两个版本目前依赖的是v1.1.2版本如果库更新到了v1.2.3版本,立刻使用go get -u并不会更新到v1.2.3,执行go get -u github.com/winlinvip/mod_minor_versions也一样不会更新,除非显式更新go get github.com/winlinvip/mod_minor_versions@v1.2.3才会使用这个版本,需要等一定时间后才会更新。
  • 对于大版本比如v2必须用go.mod描述直接引用也可以比如go get github.com/winlinvip/mod_major_error@v2.0.0,会提示v2.0.0+incompatible意思就是默认都是v0和v1而直接打了v2.0.0的tag虽然版本上匹配到了但实际上是把v2当做v1在用有可能会有不兼容的问题。或者说一般来说v2.0.0的这个tag一定会有接口的变更否则就不能叫v2了如果没有用go.mod会把这个认为是v1自然可能会有兼容问题了。
  • 更新大版本时必须带版本号比如go get github.com/winlinvip/mod_major_releases/v2@v2.0.1如果路径中没有这个v2则会报错无法更新比如go get github.com/winlinvip/mod_major_releases@v2.0.1,错误消息是invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1这个就是说mod_major_releases这个下面有go.mod描述的版本是v0或v1但后面指定的版本是@v2所以不匹配无法更新。
  • 和上面的问题一样如果在go.mod中大版本路径中没有带版本比如require github.com/winlinvip/mod_major_releases v2.0.3,一样会报错module contains a go.mod file, so major version must be compatible: should be v0 or v1这个有点含糊因为包定义的go.mod是v2的这个错误的意思是require的那个地方要求的是v0或v1而实际上版本是v2.0.3,这个和手动要求更新go get github.com/winlinvip/mod_major_releases@v2.0.1是一回事。
  • 注意三大岗位有cache比如mod_major_error@v5.0.0的go.mod描述有错误应该是v5而不是v3。如果在打完tag后获取了这个版本go get github.com/winlinvip/mod_major_error/v5,会提示错误but does not contain package github.com/winlinvip/mod_major_error/v5等错误如果删除这个tag后再推v5.0.0还是一样的错误因为index和goproxy有缓存这个版本的信息。解决版本就是升一个版本v5.0.1,直接获取这个版本就可以,比如go get github.com/winlinvip/mod_major_error/v5@v5.0.1,这样才没有问题。详细参考Semantic versions and modules
  • 和上面一样的问题如果在版本没有发布时就有go get的请求会造成版本发布后也无法获取这个版本。比如github.com/winlinvip/mod_major_error没有打版本v3.0.1,就请求go get github.com/winlinvip/mod_major_error/v3@v3.0.1会提示没有这个版本。如果后面再打这个tag就算有这个tag后也会提示401找不到 reading https://sum.golang.org/lookup/github.com/winlinvip/mod_major_error/v3@v3.0.1: 410 Gone。只能再升级个版本打个新的tag比如v3.0.2才能获取到。

总结起来说:

  • GOPATH自从默认为$HOME/go很好用依赖的包都缓存在这个公共的地方只要项目不大完全是很直接很好用的方案。一般情况下也够用了估计GOPATH可能会被长期使用毕竟习惯才是最可怕的习惯是活的最久的习惯就成为了一种生活方式用余老师的话说“文化是一种精神价值和生活方式最终体现了集体人格”。
  • vendorvendor缓存依赖在项目本地能解决很多问题了比GOPATH更好的是对于依赖可以定期更新一般的项目中对于依赖都是有需要了去更新而不是每次编译都去取最新的代码。所以vendor还是非常实用的如果能保持比较克制不要因为要用一个函数就要依赖一个包结果这个包依赖了十个这十个又依赖了百个。
  • vgo/modules代码使用上没有差异在版本更新时比如明确需要导入v2的包才会在导入url上有差异代码缓存上使用proxy来下载缓存在GOPATH的pkg中由于有版本信息所以不会有冲突会更安全因为有sum在会更灵活因为有index和proxy在。

如何无缝迁移?

现有GOPATH和vendor的项目如何迁移到modules呢官方的迁移指南Migrating to Go Modules,说明了项目会有三种状态:

  • 完全新的还没开始的项目。那么就按照上面的方式用modules就好了。
  • 现有的项目使用了其他依赖管理也就是vendor比如dep或glide等。go mod会将现有的格式转换成modules支持的格式参考这里。其实modules还是会继续支持vendor参考下面的详细描述。
  • 现有的项目没有使用任何依赖管理也就是GOPATH。注意go mod init的包路径需要和之前导出的一样特别是Go1.4支持的import comment,可能和仓库的路径并不相同,比如仓库在https://go.googlesource.com/lint,而包路径是golang.org/x/lint

Note: 特别注意如果是库支持了v2及以上的版本那么路径中一定需要包含v2比如github.com/russross/blackfriday/v2。而且需要更新引用了这个包的v2的库比较蛋疼不过这种情况还好是不多的。

咱们先看一个使用GOPATH的例子我们新建一个测试包先以GOPATH方式提供参考github.com/winlinvip/mod_gopath,依赖于github.com/pkg/errorsrsc.io/quotegithub.com/gorilla/websocket

再看一个vendor的例子将这个GOPATH的项目转成vendor项目参考github.com/winlinvip/mod_vendor,安装完dep后执行dep init就可以了,可以查看依赖:

chengli.ycl$ dep status
PROJECT                       CONSTRAINT  VERSION   REVISION  LATEST    PKGS USED
github.com/gorilla/websocket  ^1.4.1      v1.4.1    c3e18be   v1.4.1    1
github.com/pkg/errors         ^0.8.1      v0.8.1    ba968bf   v0.8.1    1
golang.org/x/text             v0.3.2      v0.3.2    342b2e1   v0.3.2    6
rsc.io/quote                  ^3.1.0      v3.1.0    0406d72   v3.1.0    1
rsc.io/sampler                v1.99.99    v1.99.99  732a3c4   v1.99.99  1

接下来转成modules包先拷贝一份github.com/winlinvip/mod_gopath代码(这里为了演示差别所以拷贝了一份,直接转换也是可以的),变成github.com/winlinvip/mod_gopath_vgo,然后执行命令go mod init github.com/winlinvip/mod_gopath_vgo && go test ./... && go mod tidy,接着发布版本比如git add . && git commit -am "Migrate to vgo" && git tag v1.0.1 && git push origin v1.0.1:

Mac:mod_gopath_vgo chengli.ycl$ cat go.mod
module github.com/winlinvip/mod_gopath_vgo
go 1.13
require (
	github.com/gorilla/websocket v1.4.1
	github.com/pkg/errors v0.8.1
	rsc.io/quote v1.5.2
)

depd的vendor的项目也是一样的先拷贝一份github.com/winlinvip/mod_vendorgithub.com/winlinvip/mod_vendor_vgo,执行命令go mod init github.com/winlinvip/mod_vendor_vgo && go test ./... && go mod tidy,接着发布版本比如git add . && git commit -am "Migrate to vgo" && git tag v1.0.3 && git push origin v1.0.3

module github.com/winlinvip/mod_vendor_vgo
go 1.13
require (
	github.com/gorilla/websocket v1.4.1
	github.com/pkg/errors v0.8.1
	golang.org/x/text v0.3.2 // indirect
	rsc.io/quote v1.5.2
	rsc.io/sampler v1.99.99 // indirect
)

这样就可以在其他项目中引用它了:

package main

import (
	"fmt"
	"github.com/winlinvip/mod_gopath"
	"github.com/winlinvip/mod_gopath/core"
	"github.com/winlinvip/mod_vendor"
	vcore "github.com/winlinvip/mod_vendor/core"
	"github.com/winlinvip/mod_gopath_vgo"
	core_vgo "github.com/winlinvip/mod_gopath_vgo/core"
	"github.com/winlinvip/mod_vendor_vgo"
	vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
)

func main() {
	fmt.Println("mod_gopath is", mod_gopath.Version(), core.Hello(), core.New("gopath"))
	fmt.Println("mod_vendor is", mod_vendor.Version(), vcore.Hello(), vcore.New("vendor"))
	fmt.Println("mod_gopath_vgo is", mod_gopath_vgo.Version(), core_vgo.Hello(), core_vgo.New("vgo(gopath)"))
	fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
}

Note: 对于私有项目可能无法使用三大件来索引校验那么可以设置GOPRIVATE来禁用校验参考Module configuration for non public modules

vgo with vendor

Vendor并非不能用可以用modules同时用vendor参考How do I use vendoring with modules? Is vendoring going away?其实vendor并不会消亡Go社区有过详细的讨论vgo & vendoring决定在modules中支持vendor有人觉得把vendor作为modules的存储目录挺好的啊。在modules中开启vendor有几个步骤

  1. 先转成modules参考前面的步骤也可以新建一个modules例如go mod init xxx然后把代码写好就是一个标准的module不过文件是存在$GOPATH/pkg的,参考github.com/winlinvip/mod_vgo_with_vendor@v1.0.0
  2. go mod vendor这一步做的事情就是将modules中的文件都放到vendor中来。当然由于go.mod也存在当然也知道这些文件的版本信息也不会造成什么问题只是新建了一个vendor目录而已。在别人看起来这就是这正常的modules和vendor一点影响都没有。参考github.com/winlinvip/mod_vgo_with_vendor@v1.0.1
  3. go build -mod=vendor修改mod这个参数默认是会忽略这个vendor目录了加上这个参数后就会从vendor目录加载代码可以把$GOPATH/pkg删掉发现也不会下载代码。当然其他也可以加这个flag比如go test -mod=vendor ./...或者go run -mod=vendor .

调用这个包时先使用modules把依赖下载下来比如go mod init private.me/app && go run t.go

package main

import (
	"fmt"
	"github.com/winlinvip/mod_vendor_vgo"
	vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
	"github.com/winlinvip/mod_vgo_with_vendor"
	vvgo_core "github.com/winlinvip/mod_vgo_with_vendor/core"
)

func main() {
	fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
	fmt.Println("mod_vgo_with_vendor is", mod_vgo_with_vendor.Version(), vvgo_core.Hello(), vvgo_core.New("vgo with vendor"))
}

然后一样的也要转成vendor执行命令go mod vendor && go run -mod=vendor t.go。如果有新的依赖的包需要导入则需要先使用modules方式导入一次然后go mod vendor拷贝到vendor。其实一句话来说modules with vendor就是最后提交代码时把依赖全部放到vendor下面的一种方式。

Note: IDE比如goland的设置里面有个Preferences /Go /Go Modules(vgo) /Vendoring mode这样会从项目的vendor目录解析而不是从全局的cache。如果不需要导入新的包可以默认开启vendor方式执行命令go env -w GOFLAGS='-mod=vendor'

Concurrency&Control

并发是服务器的基本问题,并发控制当然也是基本问题Go并不能避免这个问题只是将这个问题更简化。

Concurrency

早在十八年前的1999年千兆网卡还是一个新玩意儿想当年有吉比特带宽却只能支持10K客户端还是个值得研究的问题毕竟Nginx在2009年才出来在这之前大家还在内核折腾过HTTP服务器服务器领域还在讨论如何解决C10K问题,C10K中文翻译在这里。读这个文章,感觉进入了繁忙服务器工厂的车间,成千上万错综复杂的电缆交织在一起,甚至还有古老的惊群(thundering herd)问题惊群像远古狼人一样就算是在21世纪还是偶然能听到它的传说。现在大家讨论的都是如何支持C10M,也就是千万级并发的问题。

并发无疑是服务器领域永远无法逃避的话题是服务器软件工程师的基本能力。Go的撒手锏之一无疑就是并发处理如果要从Go众多优秀的特性中挑一个那就是并发和工程化如果只能选一个的话那就是并发的支持。大规模软件或者云计算很大一部分都是服务器编程服务器要处理的几个基本问题并发、集群、容灾、兼容、运维这些问题都可以因为Go的并发特性得到改善按照《人月神话》的观点,并发无疑是服务器领域的固有复杂度(Essential Complexity)之一。Go之所以能迅速占领云计算的市场Go的并发机制是至关重要的。

借用《人月神话》中关于固有复杂度(Essential Complexity)的概念,能比较清晰的说明并发问题。就算没有读过这本书,也肯定听过软件开发“没有银弹”,要保持软件的“概念完整性Brooks作为硬件和软件的双重专家和出色的教育家始终活跃在计算机舞台上在计算机技术的诸多领域中都作出了巨大的贡献在1964年(33岁)领导了IBM System/360IBM OS/360的研发于1993年(62岁)获得冯诺依曼奖并于1999年(68岁)获得图灵奖在2010年(79岁)获得虚拟现实(VR)的奖项IEEE Virtual Reality Career Award (2010)

在软件领域,很少能有像《人月神话》一样具有深远影响力和畅销不衰的著作。Brooks博士为人们管理复杂项目提供了具有洞察力的见解既有很多发人深省的观点又有大量软件工程的实践。本书内容来自Brooks博士在IBM公司System/360家族和OS/360中的项目管理经验,该项目堪称软件开发项目管理的典范。该书英文原版一经面世,即引起业内人士的强烈反响,后又译为德、法、日、俄、中、韩等多种文字,全球销售数百万册。确立了其在行业内的经典地位。

Brooks是我最崇拜的人有理论有实践懂硬件懂软件致力于大规模软件(当初还没有云计算)系统,足够(长达十年甚至二十年)的预见性,孜孜不倦奋斗不止,强烈推荐软件工程师读《人月神话》

短暂的广告回来,继续讨论并发(Concurrency)的问题要理解并发的问题就必须从了解并发问题本身以及并发处理模型开始。2012年我在当时中国最大的CDN公司蓝汛设计和开发流媒体服务器时,学习了以高并发闻名的NGINX的并发处理机制EDSM(Event-Driven State Machine Architecture)自己也照着这套机制实现了一个流媒体服务器和HTTP的Request-Response模型不同流媒体的协议比如RTMP非常复杂中间状态非常多特别是在做到集群Edge时和上游服务器的交互会导致系统的状态机翻倍当时请教了公司的北美研发中心的架构师MichaelMichael推荐我用一个叫做ST(StateThreads)的技术解决这个问题ST实际上使用setjmp和longjmp实现了用户态线程或者叫协程协程和goroutine是类似的都是在用户空间的轻量级线程当时我本没有懂为什么要用一个完全不懂的协程的东西后来我花时间了解了ST后豁然开朗原来服务器的并发处理有几种典型的并发模型流媒体服务器中超级复杂的状态机也广泛存在于各种服务器领域中属于这个复杂协议服务器领域不可Remove的一种固有复杂度(Essential Complexity)

我翻译了ST(StateThreads)总结的并发处理模型高性能、高并发、高扩展性和可读性的网络服务器架构State Threads for Internet Applications这篇文章也是理解Go并发处理的关键本质上ST就是C语言的协程库腾讯微信也开源过一个libco协程库而goroutine是Go语言级别的实现本质上他们解决的领域问题是一样的当然goroutine会更广泛一些ST只是一个网络库。我们一起看看并发的本质目标一起看图说话吧先从并发相关的性能和伸缩性问题说起:

image.png

  • 横轴是客户端的数目,纵轴是吞吐率也就是正常提供服务需要能吐出的数据,比如1000个客户端在观看500Kbps码率的视频时意味着每个客户端每秒需要500Kb的数据那么服务器需要每秒吐出500*1000Kb=500Mb的数据才能正常提供服务如果服务器因为性能问题CPU跑满了都无法达到500Mbps的吞吐率客户端必定就会开始卡顿。
  • 图中黑色的线是客户端要求的最低吞吐率假设每个客户端都是一样的那么黑色的线就是一条斜率固定的直线也就是客户端越多吞吐率就越多基本上和客户端数目成正比。比如1个客户端需要500Kbps的吞吐率1000个就是500Mbps吞吐率。
  • 图中蓝色的实线是服务器实际能达到的吞吐率。在客户端比较少时由于CPU空闲服务器如果有需要能够以超过客户端要求的最低吞吐率给数据比如点播服务器的场景客户端看500Kbps码率的点播视频每秒最少需要500Kb的数据那么服务器可以以800Kbps的吞吐率给客户端数据这样客户端自然不会卡顿客户端会将数据保存在自己的缓冲区只是如果用户放弃播放这个视频时会导致缓存的数据浪费。
  • 图中蓝色实线会有个天花板也就是服务器在给定的CPU资源下的最高吞吐率比如某个版本的服务器再4CPU下由于性能问题只能达到1Gbps的吞吐率那么黑线和蓝线的交叉点就是这个服务器能正常服务的最多客户端比如2000个。理论上如果超过这个最大值比如10K个服务器吞吐率还是保持在最大吞吐率比如1Gbps但是由于客户端的数目持续增加需要继续消耗系统资源比如10K个FD和线程的切换会抢占用于网络收发的CPU时间那么就会出现蓝色虚线也就是超负载运行的服务器吞吐率会降低导致服务器无法正常服务已经连接的客户端。
  • 负载伸缩性(Load Scalability)就是指黑线和蓝线的交叉点系统的负载能力如何或者说是否并发模型能否尽可能的将CPU用在网络吞吐上而不是程序切换上比如多进程的服务器负载伸缩性就非常差有些空闲的客户端也会Fork一个进程服务这无疑是浪费了CPU资源的。同时多进程的系统伸缩性会很好增加CPU资源时吞吐率基本上都是线性的。
  • 系统伸缩性(System Scalability)是指吞吐率是否随系统资源线性增加比如新增一倍的CPU是否吞吐率能翻倍。图中绿线就是增加了一倍的CPU那么好的系统伸缩性应该系统的吞吐率也要增加一倍。比如多线程程序中由于要对竞争资源加锁或者多线程同步增加的CPU并不能完全用于吞吐率多线程模型的系统伸缩性就不如多进程模型。

并发的模型包括几种,总结Existing Architectures如下表:

Arch Load Scalability System Scalability Robust Complexity Example
Multi-Process Poor Good Great Simple Apache1.x
Multi-Threaded Good Poor Poor Complex Tomcat, FMS/AMS
Event-Driven
State Machine
Great Great Good Very
Complex
Nginx, CRTMPD
StateThreads Great Great Good Simple SRS, Go
  • MP(Multi-Process)多进程模型每个连接Fork一个进程服务。系统的鲁棒性非常好连接彼此隔离互不影响就算有进程挂掉也不会影响其他连接。负载伸缩性(Load Scalability)非常差(Poor)系统在大量进程之间切换的开销太大无法将尽可能多的CPU时间使用在网络吞吐上比如4CPU的服务器启动1000个繁忙的进程基本上无法正常服务。系统伸缩性(System Scalability)非常好增加CPU时一般系统吞吐率是线性增长的。目前比较少见纯粹的多进程服务器了特别是一个连接一个进程这种。虽然性能很低但是系统复杂度低(Simple),进程很独立,不需要处理锁或者状态。
  • MT(Multi-Threaded)多线程模型:有的是每个连接一个线程,改进型的是按照职责分连接,比如读写分离的线程,几个线程读,几个线程写。系统的鲁棒性不好(Poor),一个连接或线程出现问题,影响其他的线程,彼此互相影响。负载伸缩性(Load Scalability)比较好(Good),线程比进程轻量一些,多个用户线程对应一个内核线程,但出现被阻塞时性能会显著降低,变成和多进程一样的情况。系统伸缩性(System Scalability)比较差(Poor)主要是因为线程同步就算用户空间避免锁在内核层一样也避免不了增加CPU时一般在多线程上会有损耗并不能获得多进程那种几乎线性的吞吐率增加。多线程的复杂度(Complex)也比较高,主要是并发和锁引入的问题。
  • EDSM(Event-Driven State Machine)事件驱动的状态机。比如select/poll/epoll一般是单进程单线程这样可以避免多进程的锁问题为了避免单程的系统伸缩问题可以使用多进程单线程比如NGINX就是这种方式。系统鲁棒性比较好(Good),一个进程服务一部分的客户端,有一定的隔离。负载伸缩性(Load Scalability)非常好(Great)没有进程或线程的切换用户空间的开销也非常少CPU几乎都可以用在网络吞吐上。系统伸缩性(System Scalability)很好,多进程扩展时几乎是线性增加吞吐率。虽然效率很高,但是复杂度也非常高(Very Complex),需要维护复杂的状态机,特别是两个耦合的状态机,比如客户端服务的状态机和回源的状态机。
  • ST(StateThreads)协程模型。在EDSM的基础上解决了复杂状态机的问题从堆开辟协程的栈将状态保存在栈中在异步IO等待(EAGAIN)时,主动切换(setjmp/longjmp)到其他的协程完成IO。也就是ST是综合了EDSM和MT的优势不过ST的线程是用户空间线程而不是系统线程用户空间线程也会有调度的开销不过比系统的开销要小很多。协程的调度开销和EDSM的大循环的开销差不多需要循环每个激活的客户端逐个处理。而ST的主要问题在于平台的适配由于glibc的setjmp/longjmp是加密的无法修改SP栈指针所以ST自己实现了这个逻辑对于不同的平台就需要自己适配目前Linux支持比较好Windows不支持另外这个库也不在维护有些坑只能绕过去比较偏僻使用和维护者都很少比如ST Patch修复了一些问题。

我将Go也放在了ST这种模型中虽然它是多线程+协程和SRS不同是多进程+协程SRS本身是单进程+协程可以扩展为多进程+协程)。

从并发模型看Go的goroutineGo有ST的优势没有ST的劣势这就是Go的并发模型厉害的地方了。当然Go的多线程是有一定开销的并没有纯粹多进程单线程那么高的负载伸缩性在活跃的连接过多时可能会激活多个物理线程导致性能降低。也就是Go的性能会比ST或EDSM要差而这些性能用来交换了系统的维护性个人认为很值得。除了goroutine另外非常关键的就是chan。Go的并发实际上并非只有goroutine而是goroutine+chanchan用来在多个goroutine之间同步。实际上在这两个机制上还有标准库中的context这三板斧是Go的并发的撒手锏。

由于Go是多线程的关于多线程或协程同步除了chan也提供了Mutex其实这两个都是可以用的而且有时候比较适合用chan而不是用Mutex有时候适合用Mutex不适合用chan参考Mutex or Channel

Channel Mutex
passing ownership of data,
distributing units of work,
communicating async results
caches,
state

特别提醒不要惧怕使用Mutex不要什么都用chan千里马可以一日千里却不能抓老鼠HelloKitty跑不了多快抓老鼠却比千里马强。

Context

实际上goroutine的管理在真正高可用的程序中是非常必要的我们一般会需要支持几种gorotine的控制方式

  1. 错误处理比如底层函数发生错误后我们是忽略并告警比如只是某个连接受到影响还是选择中断整个服务比如LICENSE到期
  2. 用户取消比如升级时我们需要主动的迁移新的请求到新的服务或者取消一些长时间运行的goroutine这就叫热升级。
  3. 超时关闭比如请求的最大请求时长是30秒那么超过这个时间我们就应该取消请求。一般客户端的服务响应是有时间限制的。
  4. 关联取消:比如客户端请求服务器,服务器还要请求后端很多服务,如果中间客户端关闭了连接,服务器应该中止,而不是继续请求完所有的后端服务。

而goroutine的管理最开始只有chan和sync需要自己手动实现goroutine的生命周期管理参考Go Concurrency Patterns: Timing out, moving onGo Concurrency Patterns: Context这些都是goroutine的并发范式。

直接使用原始的组件管理goroutine太繁琐了后来在一些大型项目中出现了context这些库并且Go1.7之后变成了标准库的一部分。具体参考GOLANG使用Context管理关联goroutine以及GOLANG使用Context实现传值、超时和取消

Context也有问题

  1. 支持Cancel、Timeout和Value这些都是扩张Context树的节点。Cancel和Timeout在子树取消时会删除子树不会一直膨胀Value没有提供删除的函数如果他们有公共的根节点会导致这个Context树越来越庞大所以Value类型的Context应该挂在Cancel的Context树下面这样在取消时GC会回收。
  2. 会导致接口不一致或者奇怪比如io.Reader其实第一个参数应该是context比如Read(Context, []byte)函数。或者提供两套接口一种带Contex一种不带Context。这个问题还蛮困扰人的一般在应用程序中推荐第一个参数是Context。
  3. 注意Context树如果因为Closure导致树越来越深会有调用栈的性能问题。比如十万个长链会导致CPU占用500%左右。

备注关于对Context的批评可以参考Context should go away for Go 2作者觉得在标准库中加context作为第一个参数不能理解比如Read(ctx context.Context等。

Engineering

我觉得Go在工程上良好的支持是Go能够在服务器领域有一席之地的重要原因。这里说的工程友好包括

  • gofmt保证代码的基本一致增加可读性避免在争论不清楚的地方争论。
  • 原生支持的profiling为性能调优和死锁问题提供了强大的工具支持。
  • utest和coverage持续集成为项目的质量提供了良好的支撑。
  • example和注释让接口定义更友好合理让库的质量更高。

GOFMT规范编码

这几天朋友圈霸屏的新闻是码农因为代码不规范问题枪击同事,虽然实际上枪击案可能不是因为代码规范,但可以看出大家对于代码规范问题能引发枪击是毫不怀疑的。这些年在不同的公司码代码,和不同的人一起码代码,每个地方总有人喜欢纠结于if ()中是否应该有空格甚至还大开怼戒。Go语言从来不会有这种争论因为有gofmt,语言的工具链支持了格式化代码,避免大家在代码风格上白费口舌。

比如,下面的代码看着真是揪心,任何语言都可以写出类似的一坨代码:

package main
import (
    "fmt"
    "strings"
)
func foo()[]string {
    return []string{"gofmt","pprof","cover"}}

func main() {
    if v:=foo();len(v)>0{fmt.Println("Hello",strings.Join(v,", "))}
}

如果有几万行代码都是这样,是不是有扣动扳机的冲动?如果我们执行下gofmt -w t.go之后,就变成下面的样子:

package main

import (
	"fmt"
	"strings"
)

func foo() []string {
	return []string{"gofmt", "pprof", "cover"}
}

func main() {
	if v := foo(); len(v) > 0 {
		fmt.Println("Hello", strings.Join(v, ", "))
	}
}

是不是心情舒服多了gofmt只能解决基本的代码风格问题虽然这个已经节约了不少口舌和唾沫我想特别强调几点:

  • 有些IDE会在保存时自动gofmt如果没有手动运行下命令gofmt -w .,可以将当前目录和子目录下的所有文件都格式化一遍,也很容易的是不是。
  • gofmt不识别空行因为空行是有意义的因为空行有意义所以gofmt不知道如何处理而这正是很多同学经常犯的问题。
  • gofmt有时候会因为对齐问题导致额外的不必要的修改这不会有什么问题但是会干扰CR从而影响CR的质量。

先看空行问题,不能随便使用空行,因为空行有意义。不能在不该空行的地方用空行,不能在该有空行的地方不用空行,比如下面的例子:

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	f, err := os.Open(os.Args[1])

	if err != nil {

		fmt.Println("show file err %v", err)
		os.Exit(-1)
	}
	defer f.Close()
	io.Copy(os.Stdout, f)
}

上面的例子看起来就相当的奇葩,ifos.Open之间没有任何原因需要个空行,结果来了个空行;而deferio.Copy之间应该有个空行却没有个空行。空行是非常好的体现了逻辑关联的方式,所以空行不能随意,非常严重影响可读性,要么就是一坨东西看得很费劲,要么就是突然看到两个紧密的逻辑身首异处,真的让人很诧异。上面的代码可以改成这样,是不是看起来很舒服了:

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	f, err := os.Open(os.Args[1])
	if err != nil {
		fmt.Println("show file err %v", err)
		os.Exit(-1)
	}
	defer f.Close()

	io.Copy(os.Stdout, f)
}

再看gofmt的对齐问题一般出现在一些结构体有长短不一的字段比如统计信息比如下面的代码

package main

type NetworkStat struct {
	IncomingBytes int `json:"ib"`
	OutgoingBytes int `json:"ob"`
}

func main() {
}

如果新增字段比较长,会导致之前的字段也会增加空白对齐,看起来整个结构体都改变了:

package main

type NetworkStat struct {
	IncomingBytes          int `json:"ib"`
	OutgoingBytes          int `json:"ob"`
	IncomingPacketsPerHour int `json:"ipp"`
	DropKiloRateLastMinute int `json:"dkrlm"`
}

func main() {
}

比较好的解决办法就是用注释,添加注释后就不会强制对齐了。

Profile性能调优

性能调优是一个工程问题关键是测量后优化而不是盲目优化。Go提供了大量的测量程序的工具和机制包括Profiling Go Programs, Introducing HTTP Tracing我们也在性能优化时使用过Go的Profiling原生支持是非常便捷的。

对于多线程同步可能出现的死锁和竞争问题Go提供了一系列工具链比如Introducing the Go Race Detector, Data Race Detector不过打开race后有明显的性能损耗不应该在负载较高的线上服务器打开会造成明显的性能瓶颈。

推荐服务器开启http profiling侦听在本机可以避免安全问题需要profiling时去机器上把profile数据拿到后拿到线下分析原因。实例代码如下

package main

import (
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {
	go http.ListenAndServe("127.0.0.1:6060", nil)

	for {
		b := make([]byte, 4096)
		for i := 0; i < len(b); i++ {
			b[i] = b[i] + 0xf
		}
		time.Sleep(time.Nanosecond)
	}
}

编译成二进制后启动go mod init private.me && go build . && ./private.me,在浏览器访问页面可以看到各种性能数据的导航:http://localhost:6060/debug/pprof/

例如分析CPU的性能瓶颈可以执行go tool pprof private.me http://localhost:6060/debug/pprof/profile默认是分析30秒内的性能数据进入pprof后执行top可以看到CPU使用最高的函数

(pprof) top
Showing nodes accounting for 42.41s, 99.14% of 42.78s total
Dropped 27 nodes (cum <= 0.21s)
Showing top 10 nodes out of 22
      flat  flat%   sum%        cum   cum%
    27.20s 63.58% 63.58%     27.20s 63.58%  runtime.pthread_cond_signal
    13.07s 30.55% 94.13%     13.08s 30.58%  runtime.pthread_cond_wait
     1.93s  4.51% 98.64%      1.93s  4.51%  runtime.usleep
     0.15s  0.35% 98.99%      0.22s  0.51%  main.main

除了top还可以输入web命令看调用图还可以用go-torch看火焰图等。

UTest和Coverage

当然工程化少不了UTest和覆盖率关于覆盖Go也提供了原生支持The cover story一般会有专门的CISE集成测试环境。集成测试之所以重要是因为随着代码规模的增长有效的覆盖能显著的降低引入问题的可能性。

什么是有效的覆盖一般多少覆盖率比较合适80%覆盖够好了吗90%覆盖一定比30%覆盖好吗?我觉得可不一定了,参考Testivus On Test Coverage。对于UTest和覆盖我觉得重点在于

  • UTest和覆盖率一定要有哪怕是0.1%也必须要有,为什么呢?因为出现故障时让老板心里好受点啊,能用数据衡量出来裸奔的代码有多少。
  • 核心代码和业务代码一定要分离强调核心代码的覆盖率才有意义比如整体覆盖了80%核心代码占5%核心代码覆盖率为10%,那么这个覆盖就不怎么有效了。
  • 除了关键正常逻辑更应该重视异常逻辑异常逻辑一般不会执行到而一旦藏有bug可能就会造成问题。有可能有些罕见的代码无法覆盖到那么这部分逻辑代码CR时需要特别人工Review。

分离核心代码是关键。可以将核心代码分离到单独的package对这个package要求更高的覆盖率比如我们要求98%的覆盖实际上做到了99.14%的覆盖)。对于应用的代码,具备可测性是非常关键的,举个我自己的例子,go-oryx这部分代码是判断哪些url是代理就不具备可测性下面是主要的逻辑

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if o := r.Header.Get("Origin"); len(o) > 0 {
			w.Header().Set("Access-Control-Allow-Origin", "*")
		}

		if proxyUrls == nil {
			......
			fs.ServeHTTP(w, r)
			return
		}

		for _, proxyUrl := range proxyUrls {
			srcPath, proxyPath := r.URL.Path, proxyUrl.Path
			......
			if proxy, ok := proxies[proxyUrl.Path]; ok {
				p.ServeHTTP(w, r)
				return
			}
		}

		fs.ServeHTTP(w, r)
	})

可以看得出来关键需要测试的核心代码在于后面如何判断URL符合定义的规范这部分应该被定义成函数这样就可以单独测试了

func shouldProxyURL(srcPath, proxyPath string) bool {
	if !strings.HasSuffix(srcPath, "/") {
		// /api to /api/
		// /api.js to /api.js/
		// /api/100 to /api/100/
		srcPath += "/"
	}

	if !strings.HasSuffix(proxyPath, "/") {
		// /api/ to /api/
		// to match /api/ or /api/100
		// and not match /api.js/
		proxyPath += "/"
	}

	return strings.HasPrefix(srcPath, proxyPath)
}

func run(ctx context.Context) error {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		......
		for _, proxyUrl := range proxyUrls {
			if !shouldProxyURL(r.URL.Path, proxyUrl.Path) {
				continue
			}

代码参考go-oryx: Extract and test URL proxy,覆盖率请看gocover: For go-oryx coverage,这样的代码可测性就会比较好,也能在有限的精力下尽量让覆盖率有效。

Note: 可见,单元测试和覆盖率,并不是测试的事情,而是代码本身应该提高的代码“可测试性”。

另外对于Go的测试还有几点值得说明

  • helper测试时如果调用某个函数出错时总是打印那个共用的函数的行数而不是测试的函数。比如test_helper.go,如果compare不调用t.Helper(),那么错误显示是hello_test.go:26: Returned: [Hello, world!], Expected: [BROKEN!],调用t.Helper()之后是hello_test.go:18: Returned: [Hello, world!], Expected: [BROKEN!]`实际上应该是18行的case有问题而不是26行这个compare函数的问题。
  • benchmark测试时还可以带Benchmark的参数不是testing.T而是testing.B执行时会动态调整一些参数比如testing.B.N还有并行执行的testing.PB. RunParallel,参考Benchamrk
  • main: 测试也是有个main函数的参考TestMain,可以做一些全局的初始化和处理。
  • doc.go: 整个包的文档描述,一般是在package http前面加说明,比如http doc的使用例子。

对于Helper还有一种思路就是用带堆栈的error参考前面关于errors的说明不仅能将所有堆栈的行数给出来而且可以带上每一层的信息。

注意如果package只暴露了interface比如go-oryx-lib: aac通过NewADTS() (ADTS, error)返回的是接口ADTS无法给ADTS的函数加Example因此我们专门暴露了一个ADTSImpl的结构体而New函数返回的还是接口这种做法不是最好的让用户有点无所适从不知道该用ADTS还是ADTSImpl。所以一种可选的办法,就是在包里面有个doc.go放说明,例如net/http/doc.go文件,就是在package http前面加说明,比如http doc的使用例子。

注释和Example

注释和Example是非常容易被忽视的我觉得应该注意的地方包括

  • 项目的README.md和Wiki这实际上就是新人指南因为新人如果能懂那么就很容易了解这个项目的大概情况很多项目都没有这个。如果没有README那么就需要看文件该看哪个文件这就让人很抓狂了。
  • 关键代码没有注释比如库的API关键的函数不好懂的代码段落。如果看标准库绝大部分可以调用的API都有很好的注释没有注释怎么调用呢只能看代码实现了如果每次调用都要看一遍实现真的很难受了。
  • 库没有Example库是一种要求很高的包就是给别人使用的包比如标准库。绝大部分的标准库的包都有Example因为没有Example很难设计出合理的API。

先看关键代码的注释,有些注释完全是代码的重复,没有任何存在的意义,唯一的存在就是提高代码的“注释率”,这又有什么用呢,比如下面代码:

wsconn *Conn //ws connection

// The RPC call.
type rpcCall struct {

// Setup logger.
if err := SetupLogger(......); err != nil {

// Wait for os signal
server.WaitForSignals(

如果注释能通过函数名看出来(比较好的函数名要能看出来它的职责),那么就不需要写重复的注释,注释要说明一些从代码中看不出来的东西,比如标准库的函数的注释:

// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
//
// HTTP/2 support is only enabled if the Listener returns *tls.Conn
// connections and they were configured with "h2" in the TLS
// Config.NextProtos.
//
// Serve always returns a non-nil error and closes l.
// After Shutdown or Close, the returned error is ErrServerClosed.
func (srv *Server) Serve(l net.Listener) error {

// ParseInt interprets a string s in the given base (0, 2 to 36) and
// bit size (0 to 64) and returns the corresponding value i.
//
// If base == 0, the base is implied by the string's prefix:
// base 2 for "0b", base 8 for "0" or "0o", base 16 for "0x",
// and base 10 otherwise. Also, for base == 0 only, underscore
// characters are permitted per the Go integer literal syntax.
// If base is below 0, is 1, or is above 36, an error is returned.
//
// The bitSize argument specifies the integer type
// that the result must fit into. Bit sizes 0, 8, 16, 32, and 64
// correspond to int, int8, int16, int32, and int64.
// If bitSize is below 0 or above 64, an error is returned.
//
// The errors that ParseInt returns have concrete type *NumError
// and include err.Num = s. If s is empty or contains invalid
// digits, err.Err = ErrSyntax and the returned value is 0;
// if the value corresponding to s cannot be represented by a
// signed integer of the given size, err.Err = ErrRange and the
// returned value is the maximum magnitude integer of the
// appropriate bitSize and sign.
func ParseInt(s string, base int, bitSize int) (i int64, err error) {

标准库做得很好的是,会把参数名称写到注释中(而不是用@param这种方式而且会说明大量的背景信息这些信息是从函数名和参数看不到的重要信息。

咱们再看Example一种特殊的test可能不会执行它的主要作用是为了推演接口是否合理当然也就提供了如何使用库的例子这就要求Example必须覆盖到库的主要使用场景。举个例子有个库需要方式SSRF攻击也就是检查HTTP Redirect时的URL规则最初我们是这样提供这个库的

func NewHttpClientNoRedirect() *http.Client {

看起来也没有问题提供一种特殊的http.Client如果发现有Redirect就返回错误那么它的Example就会是这样

func ExampleNoRedirectClient() {
	url := "http://xxx/yyy"

	client := ssrf.NewHttpClientNoRedirect()
	Req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		fmt.Println("failed to create request")
		return
	}

	resp, err := client.Do(Req)
	fmt.Printf("status :%v", resp.Status)
}

这时候就会出现问题我们总是返回了一个新的http.Client如果用户自己有了自己定义的http.Client怎么办实际上我们只是设置了http.Client.CheckRedirect这个回调函数。如果我们先写Example更好的Example会是这样

func ExampleNoRedirectClient() {
	client := http.Client{}

	//Must specify checkRedirect attribute to NewFuncNoRedirect
	client.CheckRedirect = ssrf.NewFuncNoRedirect()

	Req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		fmt.Println("failed to create request")
		return
	}

	resp, err := client.Do(Req)
}

那么我们自然知道应该如何提供接口了。

其他工程化

最近得知WebRTC有4GB的代码包括它自己的以及依赖的代码就算去掉一般的测试文件和文档也有2GB的代码编译起来真的是非常的耗时间而Go对于编译速度的优化据说是在Google有过验证的具体我们还没有到这个规模。具体可以参考Why so fast?主要是编译器本身比GCC快(5X)以及Go的依赖管理做的比较好。

Go的内存和异常处理也做得很好比如不会出现野指针虽然有空指针问题可以用recover来隔离异常的影响。而C或C++服务器,目前还没有见过没有内存问题的,上线后就是各种的野指针满天飞,总有因为野指针搞死的时候,只是或多或少罢了。

按照Go的版本发布节奏6个月就发一个版本基本上这么多版本都很稳定Go1.11的代码一共有166万行Go代码还有12万行汇编代码其中单元测试代码有32万行(占17.9%)使用实例Example有1.3万行。Go对于核心API是全部覆盖的提交有没有导致API不符合要求都有单元测试保证Go有多个集成测试环境每个平台是否测试通过也能看到这一整套机制让Go项目虽然越来越庞大但是整体研发效率却很高。

Go2 Transition

Go2的设计草案在Go 2 Draft Designs或者这里而Go1如何迁移到Go2也是我个人特别关心的问题Python2和Python3的那种不兼容的迁移方式简直就是噩梦一样的记忆。Go的提案中有一个专门说了迁移的问题参考Go2 Transition

Go2 Transition还不是最终方案,不过它也对比了各种语言的迁移,还是很有意思的一个总结。这个提案描述了在非兼容性变更时,如何给开发者挖的坑最小。

目前Go1的标准库是遵守兼容性原则的参考Go 1 compatibility guarantee这个规范保证了Go1没有兼容性问题几乎可以没有影响的升级比如从Go1.2升级到Go1.11。几乎的意思是很大概率是没有问题当然如果用了一些非常冷门的特性可能会有坑我们遇到过json解析时内嵌结构体的数据成员也得是exposed的才行而这个在老版本中是可以非exposed还遇到过cgo对于链接参数的变更导致编译失败这些问题几乎很难遇到都可以算是兼容的吧有时候只是把模糊不清的定义清楚了而已。

Go2在语言和标准库上会打破Go1的兼容性规范也就是和Go1不再兼容。不过Go是分布式开源社区在维护不能依赖于flag day还是要容许不同Go版本写的package的互操作性。先了解下各个语言如何考虑兼容性

  • C是严格向后兼容的,很早写的程序总是能在新的编译器中编译。另外新的编译器也支持指定之前的标准,比如-std=c90使用ISO C90标准编译程序。关键的特性是编译成目标文件后不同版本的C的目标文件能完美的链接成执行程序。C90实际上是对之前K&R C版本不兼容的,主要引入了volatile关键字,还有整数精度问题,还引入了trigraphs,最糟糕的是引入了undefined行为比如数组越界和整数溢出的行为未定义。从C上可以学到的是后向兼容非常重要非常小的打破兼容性也问题不大特别是可以通过编译器选项来处理能将不同版本的目标文件链接到一起是非常关键的undefined行为严重困扰开发者容易造成问题。
  • C++也是ISO组织驱动的语言和C一样也是向后兼容的。C++和C一样坑爹的地方坑到吐血比如undefined行为等等。尽管一直保持向后兼容但是新的C++代码比如C++11看起来完全不同这是因为有新的改变的特性比如很少会用裸指针比如range代替了传统的for循环这导致熟悉老C++语法的程序员看新的代码非常难受甚至看不懂。C++毋庸置疑是非常流行的但是新的语言标准在这方面没有贡献。从C++上可以学到的新东西是:尽管保持向后兼容,语言的新版本可能也会带来巨大的不同的感受(保持向后兼容并不能保证能持续看懂)。
  • Java也是向后兼容的是在字节码层面和语言层面都向后兼容尽管语言上不断的新增了关键字。Java的标准库非常庞大也不断的在更新过时的特性会被标记为deprecated并且编译时会有警告理论上一定版本后deprecated的特性会不可用。Java的兼容性问题主要在JVM解决如果用新的版本编译的字节码得用新的JVM才能执行。Java还做了一些前向兼容这个影响了字节码啥的我本身不懂Java作者也不说自己不是专家我就没仔细看了。Java上可以学到的新东西是要警惕因为保持兼容性而限制语言未来的改变。
  • Python2.7是2010年发布的目前主要是用这个版本。Python3是2006年开始开发2008年发布十年后的今天还没有迁移完成甚至主要是用的Python2而不是Python3这当然不是Go2要走的路。看起来是因为缺乏向后兼容导致的问题Python3刻意的和之前版本不兼容比如print从语句变成了一个函数string也变成了Unicode这导致和C调用时会有很多问题。没有向后兼容同时还是解释型语言这导致Python2和3的代码混着用是不可能的这以为着程序依赖的所有库必须支持两个版本。Python支持from __future__ import FEATURE这样可以在Python2中用Python3的特性。Python上可以学到的东西是向后兼容是生死攸关的和其他语言互操作的接口兼容是非常重要的能否升级到新的语言是由调用的库支持的。
  • Perl6是2000年开始开发的15年后才正式发布这也不是Go2应该走的路。这么漫长的主要原因包括刻意没有向后兼容只有语言的规范没有实现而这些规范不断的修改。Perl上可以学到的东西是不要学Perl设置期限按期交付别一下子全部改了。

特别说明的是非常高兴的是Go2不会重新走Python3的老路子当初被Python的版本兼容问题坑得不要不要的。

虽然上面只是列举了各种语言的演进确实也了解得更多了有时候描述问题本身反而更能明白解决方案。C和C++的向后兼容确实非常关键但也不是他们能有今天地位的原因C++11的新特性到底增加了多少DAU呢确实是值得思考的。另外C++11加了那么多新的语言特性比如WebRTC代码就是这样很多老C++程序员看到后一脸懵逼,和一门新的语言一样了,是否保持完全的兼容不能做一点点变更,其实也不是的。

应该将Go的语言版本和标准库的版本分开考虑这两个也是分别演进的例如alias是1.9引入的向后兼容的特性1.9之前的版本不支持1.9之后的都支持。语言方面包括:

  • Language additions新增的特性比如1.9新增的type alias。这些向后兼容的新特性并不要求代码中指定特殊的版本号比如用了alias的代码不用指定要1.9才能编译,用之前的版本会报错。向后兼容的语言新增的特性,是依靠程序员而不是工具链来维护的,要用这个特性或库升级到要求的版本就可以。
  • Language removals删除的特性。比如有个提案#3939去掉string(int)字符串构造函数不支持整数假设这个在Go1.20版本去掉那么Go1.20之后这种string(1000)代码就要编译失败了。这种情况没有特别好的办法能解决我们可以提供工具将代码自动替换成新的方式这样就算库维护者不更新使用者自己也能更新。这种场景引出了指定最大版本类似C的-std=C90,可以指定最大编译的版本比如-lang=go1.19当然必须能和Go1.20的代码链接。指定最大版本可以在go.mod中指定这需要工具链兼容历史的版本由于这种特性的删除不会很频繁维护负担还是可以接受的。
  • Minimum language version最小要求版本。为了可以更明确的错误信息,可以允许模块在go.mod中指定最小要求的版本,这不是强制性的,只是说明了这个信息后编译工具能明确的给出错误,比如给出应该用具体哪个版本。
  • Language redefinitions语言重定义。比如Go1.1时int在64位系统中长度从4字节变成了8字节这会导致很多潜在的问题。比如#20733修改了变量在for中的作用域看起来是解决潜在的问题但也可能会引入问题。引入关键字一般不会有问题不过如果和函数冲突就会有问题比如error: check。为了让Go的生态能迁移到Go2语言重定义的事情应该尽量少做因为我们不再能依赖编译器检查错误。虽然指定版本能解决这种问题但是这始终会导致未知的结果很有可能一升级Go版本就挂了。**我觉得对于语言重定义,应该完全禁止。**比如#20733可以改成禁止这种做法这样就会变成编译错误可能会帮助找到代码中潜在的BUG。
  • Build tags编译tags。在指定文件中指定编译选项是现有的机制不过是指定的release版本号它更多是指定了最小要求的版本而没有解决最大依赖版本问题。
  • Import go2导入新特性。和Python的特性一样可以在Go1中导入Go2的新特性比如可以显示的导入import "go2/type-aliases"而不是在go.mod中隐式的指定。这会导致语言比较复杂将语言打乱成了各种特性的组合。而且这种方式一旦使用将无法去掉。这种方式看起来不太适合Go。

如果有更多的资源来维护和测试标准库后续会更快发布虽然还是6个月的周期。标准库方面的变更包括

  • Core standard library核心标准库。有些和编译工具链相关的库还有其他的一些关键的库应该遵守6个月的发布周期而且这些核心标准库应该保持Go1的兼容性,比如os/signalreflectruntimesynctestingtimeunsafe等等。我可能乐观的估计net, os, 和syscall不在这个范畴。
  • Penumbra standard library边缘标准库。它们被独立维护但是在一个release中一起发布当前核心库大部分都属于这种。这使得可以用go get等工具来更新这些库比6个月的周期会更快。标准库会保持和前面版本的编译兼容,至少和前面一个版本兼容
  • Removing packages from the standard library去掉一些不太常用的标准库,比如net/http/cgi等。

如果上述的工作做得很好的话开发者会感觉不到有个大版本叫做Go2或者这种缓慢而自然的变化逐渐全部更新成了Go2。甚至我们都不用宣传有个Go2既然没有C2.0为何要Go2.0呢主流的语言比如C、C++和Java从来没有2.0一直都是1.N的版本我们也可以模仿他们。事实上一般所认为的全新的2.0版本,若出现不兼容性的语言和标准库,对用户也不是个好结果,甚至还是有害的。

Others

关于Go还有哪些重要的技术值得了解的下面详细分享。

GC

GC一般是C/C++程序员对于Go最常见也是最先想到的一个质疑GC这玩意儿能行吗我们以前C/C++程序都是自己实现内存池的,我们内存分配算法非常牛逼的。

Go的GC优化之路可以详细读Getting to Go: The Journey of Go's Garbage Collector

2014年Go1.4GC还是很弱的是决定Go生死的大短板。

image.png

上图是Twitter的线上服务监控。Go1.4的STW(Stop the World) Pause time是300毫秒而Go1.5优化到了30毫秒。

而Go1.6的GC暂停时间降低到了3毫秒左右。

Go1.8则降低到了0.5毫秒左右也就是500微秒。从Go1.4到Go1.8优化了600倍性能。

如何看GC的STW时间呢可以引入net/http/pprof这个库然后通过curl来获取数据实例代码如下

package main

import (
	"net/http"
	_ "net/http/pprof"
)

func main() {
	http.ListenAndServe("localhost:6060", nil)
}

启动程序后执行命令就可以拿到结果由于上面的例子中没有GC下面的数据取的是另外程序的部分数据

$ curl 'http://localhost:6060/debug/pprof/allocs?debug=1' 2>/dev/null |grep PauseNs
# PauseNs = [205683 79032 202102 82216 104853 142320 90058 113638 152504
145965 72047 49690 158458 60499 99610 112754 122262 52252 49234 68420 159857
97940 226085 103644 135428 245291 141997 92470 79974 132817 74634 65653 73582
47399 51653 86107 48619 62583 68906 131868 111903 85482 44531 74585 50162
31445 107397 10903081771 92603 58585 96620 40416 29763 102248 32804 49394
83715 77099 108983 66133 47832 35379 143949 69235 27820 35677 99430 104303
132657 63542 39434 126418 63845 167969 116438 68904 77899 136506 119708 47501]

可以用python计算最大值是322微秒最小是26微秒平均值是81微秒。

Declaration Syntax

关于Go的声明语法Go Declaration Syntax和C语言有对比The "Clockwise/Spiral Rule"这个文章中也详细描述了C的顺时针语法规则。其中有个例子

int (*signal(int, void (*fp)(int)))(int);

这个是个什么呢翻译成Go语言就能看得很清楚

func signal(a int, b func(int)) func(int)int

signal是个函数有两个参数返回了一个函数指针。signal的第一个参数是int第二个参数是一个函数指针。

当然实际上C语言如果借助typedef也是能获得比较好的可读性的

typedef void (*PFP)(int);
typedef int (*PRET)(int);
PRET signal(int a, PFP b);

只是从语言的语法设计上来说还是Go的可读性确实会好一些。这些点点滴滴的小傲娇是否可以支撑我们够浪程序员浪起来的资本呢至少Rob Pike不是拍脑袋和大腿想出来的规则嘛这种认真和严谨是值得佩服和学习的。

Documents

新的语言文档支持都很好不用买本书看Go也是一样Go官网历年比较重要的文章包括

其中,文章中有引用其他很好的文章,我也列出来哈:

SRS

SRS是使用ST单进程单线程性能是EDSM模型的nginx-rtmp的3到5倍参考SRS: Performance当然不是ST本身性能是EDSM的三倍而是说ST并不会比EDSM性能低主要还是要根据业务上的特征做优化。

关于ST和EDSM参考本文前面关于Concurrency对于协程的描述ST它是C的一个协程库EDSM是异步事件驱动模型。

SRS是单进程单线程可以扩展为多进程可以在SRS中改代码Fork子进程或者使用一个TCP代理比如TCP代理go-oryx: rtmplb

在2016年和2017年我用Go重写过SRS验证过Go使用2CPU可以跑到C10K参考go-oryxv0.1.13 Supports 10k(2CPUs) for RTMP players。由于仅仅是语言的差异而重写一个项目没有找到更好的方式或理由觉得很不值得所以还是放弃了Go语言版本只维护C++版本的SRS。Go目前一般在API服务器用得比较多能否在流媒体服务器中应用答案是肯定的我已经实现过了。

后来在2017年终于找到相对比较合理的方式来用Go写流媒体就是只提供库而不是二进制的服务器参考go-oryx-lib

目前Go可以作为SRS前面的代理实现多核的优势参考go-oryx

<<大结局-END>>

<<谢谢观赏>>