Go語言專案實踐及新特性解讀

語言: CN / TW / HK

總篇第142篇 2022年第17篇

★ 目錄 ★

01

語言介紹

02

之家專案實踐

2.1 百萬級併發狀態機 - 818臺網互動秒殺專案

2.2 嵌入式邊緣計算網路 - 直播推流窄帶高清專案

2.3 百萬級DAU使用者產品 - VR全景看車專案

03

新特性

3.1 泛型(GENERICS)

3.2 多模組工作區(WORKSPACE)

3.3 模糊測試(FUZZING)

04

總結

語言介紹

Go 是Google開發的一種 靜態強型別、編譯型、併發型 ,並具有 垃圾回收 功能的跨平臺程式語言,於2009年11月正式宣佈推出,成為開放原始碼專案,支援Linux、macOS、Windows等不同的作業系統。 GoRobert Griesemer 、Rob Pike、Ken Thompson 2007年9月開始設計,後 Ian Lance TaylorRuss 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. 客戶端發起請求建立連線請求

  1. 狀態機接收到請求後啟動一個goroutine進行連線維護,並下發第一次狀態資訊,同時把對應的指標放入map中

  2. 狀態機在收到對應值改變後,再次向所有的連線進行訊息推送廣播

在建立百萬級的websocket連線後,記憶體暴增21G左右。

發現除了socket本身的連線佔用外,程式內每條conn佔用大概在20k左右:

  1. 每個連線都產生一個Goroutine來維護連線

  2. http讀取寫入快取

  3. 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){...
  • 不支援泛型方法內建自定義型別

func F[T1 any]() {
type x struct{}
}


//build: type declarations inside genericfunctions are not currently supported
  • 不支援內嵌型別引數

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語言應用在更多的業務場景上,同時總結一些實踐經驗在公司內部進行推廣和使用。

本頁的部分內容是從Google 建立和共享的作品中複製而來,並根據Creative Commons Attribution 3.0 License中描述的條款使用。

參考資料

  • https://go.dev/blog/go1.18

  • https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

  • https://speakerdeck.com/gopherconil/eran-yanay

作者簡介

汽車之家

李廣朋

使用者產品中心-基礎產品團隊

2018年6月加入汽車之家使用者產品中心,負責全景看車、影象識別、智慧傳圖的業務開發等相關工作

閱讀更多

▼ 關注「 之家技術 」,獲取更多技術乾貨 

「其他文章」