|
一、语言介绍
Go 是Google开发的一种 静态强类型、编译型、并发型 ,并具有 垃圾回收 功能的跨平台编程语言,于2009年11月正式宣布推出,成为开放源代码项目,支持Linux、macOS、Windows等不同的操作系统。 Go 由 Robert Griesemer 、Rob Pike、Ken Thompson 2007年9月开始设计,后 Ian Lance Taylor 、 Russ Cox 加入项目。
从左到右分别是Robert Griesemer、Rob Pike和Ken Thompson
Go语言本身解决了并发编程和开发效率的痛点,语法十分简洁、丰富的标准库、goroutine和chan原生支持并发(CSP模 型) 、工具链全面并且在编译、运行上都极高效率、全自动高效的垃圾回收机制、易部署、跨平台等这些特性吸引了越来越多的人开始使用Go。 国内有不少企业在使用Go:七牛、阿里、字节、腾讯、百度、小米京东等,在云原生、区块链、游戏、微服务、基础后端许多场景下都有开发应用。许多杀手级开源项目也是基于Go开发:Docker、k8s、Etcd、Cockroach DB、以太坊(区块链)等。 近几年之家也基于Go进行了一些开发实践,这些项目也都有着很好的表现。
二、之家项目实践
近几年之家基于Go在不同的业务场景下(高并发服务、云原生、中间件、机器学习、用户产品、嵌入式等)有着丰富的开发实践,接下来会基于这些场景分享一些典型案例:
百万级并发状态机 - 818台网互动秒杀项目
818台网互动项目是汽车之家与湖南卫视联合打造的“汽车之家818全球汽车夜” ,晚会已经成功举办两届,用户可以通过观看电视并使用汽车之家App实时参与晚会互动,晚会现场和线上活动场景图如下:
线下会有大量用户通过观看电视直播,参与线上的秒杀抽奖活动,请求量非常大。在不同的活动场次,App会根据主持人在口播指令下进行活动状态切换,对实时性要求很高。下图是整个活动场次时序图:
我们在整个项目架构上增加了状态机,通过webscoket作为长连接向客户端提供实时活动状态信息的服务。 状态机底层依赖Redis集群,通过pub/sub来获取状态值的变化向客户端推送最新的状态信息,同时使用HTTP/2轮询作为备用方案,使客户端在websocket连接出问题的情况下也能获取到数据,下图是整个台网互动的架构图:
状态机对于高并发负载要求较高,晚会当晚预估量200w连接左右,而Go在并发通讯方面有着天然的优势,所以我们使用了Go语言进行开发。
同时我们也用Go http与java常用的spring框架进行了压测对比,在大量并发请求的情况下,Go可以更快的响应,内存增长也少于Java。
- goroutine& 内存优化
下面是建立websocket连接的大致流程:
1.客户端发起请求建立连接请求
2.状态机接收到请求后启动一个goroutine进行连接维护,并下发第一次状态信息,同时把对应的指针放入map中
3.状态机在收到对应值改变后,再次向所有的连接进行消息推送广播
在建立百万级的websocket连接后,内存暴增21G左右。
发现除了socket本身的连接占用外,程序内每条conn占用大概在20k左右:
- 每个连接都产生一个Goroutine来维护连接
- http读取写入缓存
- websocket框架内的读写缓存
//WS 长链接处理handler func WS(ctx *gin.Context) { //建立websocket conn, err := ws.Upgrade(ctx.Writer, ctx.Request) if err != nil { ctx.AbortWithStatus(400) return } conn.IP = IP conn.C = ctx.Query("_c") conn.UA = CheckUA(ctx.Request) conn.Referer = CheckReferer(ctx.Request) log.Debug().Str("addr", IP).Msg("ws-push new client enter -->") //推送第一次消息 if err := conn.WriteMessage(websocket.TextMessage, []byte(ws.StateMsg+cache.State.String())); err != nil { //if err := conn.WriteMessage(websocket.TextMessage, block); err != nil { log.Error(). Err(err). Str("addr", conn.RemoteAddr().String()). Msg("ws-push Can't send firstMsg -->") return } monitor.PushEnter <- conn Read(conn) } |
每个连接都新建一个Goroutine看起来似乎不是那么有必要,查阅了一些资料以后,发现可以通过epoll进行管理这些连接,通过事件通知拿到对应的conn进行处理,sys/unix包提供了操作系统原始系统调用的接口,刚好可以实现这些功能:
type epoll struct { fd int connections map[int]*websocket.Conn lock *sync.RWMutex } func MkEpoll() (*epoll, error) { fd, err := unix.EpollCreate1(0) if err != nil { return nil, err } return &epoll{ fd: fd, lock: &sync.RWMutex{}, connections: make(map[int]*websocket.Conn), }, nil } func websocketFD(conn *websocket.Conn) int { //读取fd表示返回 } func (e *epoll) Add(conn *websocket.Conn) error { fd := websocketFD(conn) err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &unix.EpollEvent{Events: unix.POLLIN | unix.POLLHUP, Fd: int32(fd)}) if err != nil { return err } e.lock.Lock() defer e.lock.Unlock() e.connections[fd] = conn if len(e.connections)%100 == 0 { log.Printf("Total number of connections: %v", len(e.connections)) } return nil } func (e *epoll) Remove(conn *websocket.Conn) error { //删除一个连接 return nil } func (e *epoll) Wait() ([]*websocket.Conn, error) { events := make([]unix.EpollEvent, 100) n, err := unix.EpollWait(e.fd, events, 100) if err != nil { return nil, err } e.lock.RLock() defer e.lock.RUnlock() var connections []*websocket.Conn for i := 0; i < n; i++ { conn := e.connections[int(events[i].Fd)] connections = append(connections, conn) } return connections, nil } |
这样在调用的时候只需要一个Goroutine去管理这些连接了:
var Epoll *epoll //WS 长链接处理handler func WS(ctx *gin.Context) { //... if err := Epoll.Add(conn); err != nil { ctx.AbortWithStatus(400) return } } func Read(){ for{ conns,err:=Epoll.Wait() if err!=nil { log.Error.Err(err).Msg("Epool wait error") continue } for _, conn := range conns{ _, msg, err := conn.ReadMessage() if err != nil { monitor.Quit <- conn } else { //业务处理 } } } } func main(){ var err error Epoll, err = MkEpoll() if err != nil { log.Error().Err(err).Send() return } go Read() //... } |
最后,使用epoll来管理将近减少了20%的内存占用。除了epoll优化内存使用以外,还可以从net包入手,实现零拷贝等方案,未来我们考虑从这些方面入手进一步优化。
嵌入式边缘计算网络 - 直播推流窄带高清项目
户外直播窄带高清推流使用了视频聚合技术,在多种的网络下通过网络叠加来进行视频传输,能保证在某个网络环境不佳的情况下,通过叠加的方式增加网络的稳定性,提升传输质量,下图是整个传输过程及特点的介绍:
网络聚合加速是基于之家团队自研的硬件设备4G/5G背包(图1),大致方案是:把AI智能编码技术从云端前置到推流边缘设备(减轻上行带宽),利用树莓派硬件平台+4块5G硬件模组+WIFI模组,内嵌OpenWRT系统,运行Go框架写的分片聚合程序进行流传输,程序使用UDP通讯传输,切片使用Autohome自研协议(图2)
(图1)
(图2)
在分片聚合方面我们使用Go进行了一个重写,Go的net库非常好用,网络轮询器中使用I/O 多路复用模型处理 I/O 操作,官方统一封装了一个网络事件池(netpoll),性能也有所保证。启动一个tcp服务仅仅需要几行代码:
package main import ( "fmt" "io" "log" "net" "net/http" "os" ) func main() { // Listen on a port listen, error := net.Listen("tcp", ":8272") // Handles eventual errors if error != nil { fmt.Println(error) return } for { // Accepts connections con, error := listen.Accept() // Handles eventual errors if error != nil { fmt.Println(error) continue } go handleConnection(con) } } |
而且Go开发嵌入式代码的时候,标准库的使用没有平台限制,内部已经兼容了不同平台的实现,而且编译的时候只需要一句话即可编译对应平台的二进制文件:
GOOS=linux GOARCH=arm64 go build xxx |
内部实现中,我们使用chan来接收完整数据和发送切片:
//这里省略了一些代码 func run_muxmiddle(listenerAddr string) { //递增序号 var orderNO *int32 = new(int32) for { NO := fmt.Sprint(atomic.AddInt32(orderNO, 1) conn, err := tcpListener.AcceptTCP() conn.SetReadBuffer(lib.MyNetReadWriteBuffer) conn.SetReadBuffer(lib.MyNetReadWriteBuffer) if err != nil {} exitChan := make(chan struct{}) go func(exit chan struct{}) { var msgidNO uint32 for { select { case <-exit: break case op := <-lib.OriginalPacketChan: //处理下行数据 break } } } }(exitChan) buf := make([]byte, lib.MyNetReadWriteBuffer) for { //上行 nr, err := conn.Read(buf) if err != nil { break } if nr > 0 { data := make([]byte, nr) copy(data, buf[0:nr]) lib.UploadChan <- data } } close(exitChan) } } |
使用Go程序进行重写后,资源利用率较之前有了一个整体的降低,得益于Go不断提升的GC性能,程序在长时间运行的稳定性方面也表现的十分亮眼。
百万级DAU用户产品 - VR全景看车项目
全景看车是之家提供的360度的车辆外观和内饰,为用户提供了沉浸式看车体验。
目前日均UV几百万,后端全部使用Go开发。同时全景看车项目也在之家内部为不同业务线提供了全景素材服务的输出接口, 在网上车展充当了至关重要的角色,承载了数十万级的并发性能。 全景看车项目依赖于之家云的各项基础服务进行部署,并且全部容器化,通过容器横向扩展的能力,可以轻松的应对不同性能需求场景,如下图所示:
随着Go版本不断更新,一些非常重要的新特性也随之而来。全景看车接口服务也针对合适的场景(防止缓存击穿)利用新特性进行了优化,性能的得到了不小的提升,下面让我们来一起看看新版本的Go都有了哪些变化。
三、新特性
在延迟了一个月后,1.18终于发布。版本带来了大量的新特性及语法变化,同时也保持了Go 1.x的兼容性承诺。
1.泛型 (Generics)
作为社区最期待的功能之一“泛型”,在筹备了几年后终于千呼万唤始出来,让我们一起来看看Go的泛型是如何定义和实现的。
类型型参( type parameter)
类型形参通过[TConstraint]的形式在方法、结构体上作为 类型约束 ,当程序调用方法或实例化结构体的时候,类型形参会被实际类型所替代。
//作用在方法上,支持多个类型参数 func F[T any](t T) { ... } func F[T1 any](t1 T1,t2 T1){...} //作用在结构体 type S[T any] struct { ... } //作用在自定义类型上 type Slice[T any] []T |
以比较两个数的大小函数为例子,以往的方法长这样:
func Less(a,b int)bool{ return a < b } func Less(a,b int64){...} |
不同的数据类型,需要声明多个不同的方法,拥有类型形参的泛型函数则是这样:
func Less[T int | int64](a,b T)bool{ return a < b } |
约束
类型参数中的Constraint就是 约束 ,它将T限定在某种范围。Go1.18的标准库中内置了两个约束类型:
- interface的别名
- any
- 所有可比较的类型
- comparable
其他的常用约束类型都定义在constraints包中,需要注意的是,该包并不在标准库中,而是在x/exp下。
约束类型也可以是一个接口,内置的comparable就是一个接口类型的约束。
typecomparable interface{ comparable } |
接口通过|符号把所有类型、方法的并集来做为约束,如果接口使用了方法,则约束的类型必须实现该方法,下面是官方的图例:
我们把开始的Less方法约束类型改成了一个Number类型的约束接口:
type MyType int
type Number interface{ ~int | ~int64 | ~float64 } func Less[T Number](a, b T) bool { return a < b } func main(){ var n MyType = 1 Less(n, 2) } |
MyType并没有加入Number的接口中,但是也可以正常调用,是因为使用了~符号。 这个符号表示取底层类型,而MyType的底层类型是int,所以在调用的时候也可以通过编译。
需要注意的是约束接口 不可以作为 类型来使用,只能作为形参使用,下面这个就是错误的用法:
type MyType []Number //定义一个Number数组,但Number不是普通接口,这里会报错 |
约束类型推断
Go可以通过类型推断进行类型自动转换,而无需显示指定约束类型:
//Less 是个类型参数实现的泛型方法,支持不同类型的比较 func Less[T int | int64 | float64](a, b T) bool { return a < b } func main() { fmt.Println(Less[int](1, 2)) //定义T类型为int fmt.Println(Less[float64](1.0, 2.0)) //定义T类型为float64 fmt.Println(Less(1.0, 2.0)) // 从1.0判断出类型为float64 fmt.Println(Less(int(1), 2.0)) // 参数1类型为int,并推断2.0可以赋值给int fmt.Println(Less(1, 2.0)) //编译失败:default type float64 of 2.0 does not match inferred type int for T } |
参数的类型推断过程会进行两次:
1. 忽略无类型常量,如果没有无类型的常量,或者已经匹配了其他输入类型,那么类型推断结束。
2. 如果还包含无类型常量,则按照Go本身的数据类型进行推断。
在第一遍的时候参数(1,2.0)都是无类型常量,所以进行了第二次类型推断1 = int, 2.0 =float64, 与Less方法传入类型不匹配,所以报错了。
使用泛型方式进行编程, 会减少许多Go反射上的使用,提升效率,下面我们来看几个例子:
- 数组排序
之前使用数组排序的时候,我们需要实现sort.Interface接口,由于不支持泛型的缘故,每种不同的类型我们都要重复一遍。有了类型参数之后,我们只需要实现一个通用的泛型方法即可:
// sort.Interface 接口定义 //type Interface interface { // Len() int // Less(i, j int) bool // Swap(i, j int) //} //Sortable 定义所有可以比较的 type Sortable interface { ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 | ~string } //SortSlice实现了sort.Interface接口方法 //一些复合类型的排序思路类似,只需要在SortSlice中再定义一个回调方法,并在每次执行Less方法中调用。 type SortSlice[T Sortable] []T func (sl SortSlice) Len() int { return len(sl) } func (sl SortSlice) Less(i, j int) bool { return sl[i] < sl[j] } func (sl SortSlice[T]) Swap(i, j int) { sl[j], sl[i] = sl[i], sl[j] } //Sort 泛型排序方法,底层调用sort.Sort //标准库由于Go1兼容性承诺,并不会使用类型参数重写 func Sort[T Sortable](sl []T) { sort.Sort(SortSlice[T](sl)) } func main() { intSlice := []int{6, 3, 4, 2, 5} floatSlice := []float64{3.1, 2.1, 1.1} Sort(intSlice) Sort[float64](floatSlice) fmt.Println(intSlice) //输出:[2 3 4 5 6] fmt.Println(floatSlice) //输出:[1.1 2.1 3.1] } |
- 防止缓存击穿(singleflight)
全景看车的api中使用singleflight库来防止缓存击穿,它可以将多个相同的并发请求合并成一个请求来减少数据库的压力。
在1.18之前,处理相同key结果每次都需要接口断言:
var g Group v, _, _ := g.Do("ext1134", func() (interface{}, error) { //不重要 return v, nil }) return v.(Exterior) //全景外观 //type Exterior struct{...} |
而在1.18中,我们可以使用泛型方法来实现,减少每次断言的成本:
var g Group[Exterior] v, _, _ := g.Do("ext1134", func() (Exterior, error) { //不重要 return v, nil }) return v |
有一点需要注意的是,如果你的返回类型不一致,那么需要声明多个Group[T],目前Go不支持结构体方法的类型参数。
性能
- 编译时间
非泛型编译中,go1.18会比go1.17慢1%左右
泛型代码中编译,go1.18会比go1.17慢15-18%左右,因为编译器会首先由types2(支持泛型)进行类型检查,然后根据这些创建一个IR Tree,这部分产生了一些耗时。
- 运行效率
所有关于泛型类型的检查都在编译期,所以执行效率和1.17版本几乎是无变化的。但使用泛型来替代减少断言的时候,速度会有明显提升。
下图是进行了遍历链表的float64值相加,速度提升了近90%:
func BenchmarkElementT_AddVal(b *testing.B) { //... for i := 0; i < b.N; i++ { for current := list.Front(); current != nil; current = current.Next() { current.Val++ } } } func BenchmarkElement_AddVal(b *testing.B) { //... for i := 0; i < b.N; i++ { for current := list.Front(); current != nil; current = current.Next() { current.Value = current.Value.(float64) + 1 } } } goos: darwin goarch: arm64 pkg: test BenchmarkElementT_AddVal BenchmarkElementT_AddVal-8 12356300 97.36 ns/op BenchmarkElement_AddVal BenchmarkElement_AddVal-8 1347231 866.8 ns/op PASS |
一些限制 目前Go1.18的泛型相对来不是特别完整,这里列出了部分限制:
不允许结构体方法上的类型参数
type List[T] sturct{...}
func (l List[T]) Push[Value T] (val Value){... |
不支持泛型方法内置自定义类型
type List[T] sturct{...}
func (l List[T]) Push[Value T] (val Value){... |
不支持内嵌类型参数
type Lockable[T any] struct { T mu sync.Mutex } //build: embedded field type cannot be a (pointer to a) type parameter |
还有一些其他的限制在1.18的发行说明都有列出,这些限制有的将会在以后的版本迭代中放开。
2.多模块工作区 (Workspace)
Go1.18发布了Workspace特性,允许用户在本地进行多个模块的同时开发编写。
以全景看车项目为例,当在增加后台需求的时候,难免要更改其他包中的方法。但以前的mod中是不能直接更改依赖包的代码的,以前都是使用replace进行本地路径包的替换,像这样:
go 1.17
require ( autohome.com/vr/dal/v2 v2.6.8 ) //这里往往容易被忽略提交到git上 replace autohome.com/vr/dal/v2 v2.6.8 => /Documents/vr/dal |
有时候难免疏忽,忘记删除mod文件中replace,提交到git后导致项目编译失败。而在Go1.18上,工作区就可以避免这样的问题发生:
go work init //创建工作区, 根目录会生成一个go.work的文件 go use ../dal //在go.work中增加dal项目 go use . //在go.work中加入当前项目,不添加则会按包名去git上找 - go.work内容如下: go 1.18 use ( /Documents/vr/dal ./ ) |
这样就不需要每次都使用replace来进行mod的替换了,需要注意的是:
go.work 需要加入.gitignore中,不需要提交到git上!
3.模糊测试 (Fuzzing)
模糊测试是go在1.18中提供的自动化测试,通过指定类型的语料库进行智能添加测试数据。 模糊测试可以覆盖很多人工经常忽略的边缘case,对于程序中漏洞特别有价值。
- 模糊测试方法名以FuzzXxx,且和其他测试用例一样,需要写在xxx_test.go中
- 模糊测试方法需要指定一个随机参数类型,目前只支持基础类型:
- 运行的时候和其他测试用例一样,指定好需要运行的用例名称即可:
gotest -fuzz={FuzzTestName} |
需要注意的是,Fuzzing目前还处于完善阶段:
- 实验性功能,不承诺Go1.x的兼容性
- 不支持结构和非原始类型的结构化模糊
- 模糊测试会消耗大量内存(测试会持续运行),影响机器运行时的性能
- 不支持字典
- 不支持结构和原始类型
四、总结
Go语言还是一门年轻的语言,1.18的更新是一个非常有意义的里程碑事件,也标志着Go进入泛型时代,随着版本的不断更新,未来Go也会越来越完善。 除了上述的一些项目外,我们正在进行Go的微服务实践,通过对业界一些Go微服务框架(go-zero、kitex、dubbo-go、tars-go)的调研使用对比,将目前的一些服务进行模块化,提高项目的可部署性和扩展性。希望未来可以把Go语言应用在更多的业务场景上,同时总结一些实践经验在公司内部进行推广和使用。