這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
我有一個習慣,那就是隨時記錄下編程過程中遇到的問題(包括問題現場、問題起因以及對問題的分析),並喜歡階段性的對一段時間內的編碼過程的得與失進行回顧和總結。內容可以包括:對編程文法的新認知、遇坑填坑的經曆、一些讓自己豁然開朗的小tip/小實踐等。記錄和總結的多了,感覺有價值的,就成文發在部落格上的;一些小的點,或是還沒有想清楚的事情,或思路沒法結構化統一的,就放在資料庫裡備用。“寫Go代碼時遇到的那些問題”這個系列也是基於這個思路做的。
在這一篇中,我把“所遇到的問題”劃分為三類:語言類、庫與工具類、實踐類,這樣應該更便於大家分類閱讀和理解。另外借這篇文章,我們先來看一下Go語言當前的State,資料來自於twitter、reddit、golang-dev forum、github上golang項目的issue/cl以及各種gophercon的talk資料。
零. Go語言目前狀態
1. vgo
Go 1.10在中國農曆春節期間正式發布。隨後Go team進入了Go 1.11的開發週期。
在2017年的Go語言使用者調查報告結果中,缺少良好的包管理工具以及Generics依然是Gopher面臨的最為棘手的挑戰和難題的Top2,Go team也終於開始認真對待這兩個問題了,尤其是包依賴管理的問題。在今年2月末,Russ Cox在自己的部落格上連續發表了七篇博文,詳細闡述了vgo – 帶版本感知和支援的Go命令列工具的設計思路和實現方案,並在3月末正式提交了”versioned-go proposal“。
目前相對成熟的包管理方案是:
"語義化版本"+manifest檔案(手工維護的依賴約束描述檔案)+lock檔案(工具自動產生的傳遞依賴描述檔案)+版本選擇引擎工具(比如dep中的gps - Go Packaging Solver)
與之相比,vgo既有繼承,更有創新。繼承的是對語義化版本的支援,創新的則是semantic import versioning、最小版本選擇minimal version selection等新機制,不變的則是對Go1文法的相容。按照Russ Cox的計劃,Go 1.11很可能會提供一個實驗性的vgo實現(當然vgo所呈現的形式估計是merge到go tools中),讓廣大gopher試用和反饋,然後會像vendor機制那樣,在後續Go版本中逐漸成為預設選項。
2. wasm porting
知名開源項目gopherjs的作者Richard Musiol上個月提交了一個proposal: WebAssembly architecture for Go,主旨在於讓Gopher也可以用Go編寫前端代碼,讓Go編寫的代碼可以在瀏覽器中運行。當然這並不是真的讓Go能像js那樣直接運行於瀏覽器或nodejs上,而是將Go編譯為WebAssembly,wasm中間位元組碼,再在瀏覽器或nodejs初始化的運行環境中運行。這雷根據自己的理解粗略畫了一幅二進位機器碼的go app與中間碼的wasm的運行層次對比圖,希望對大家有用:
wasm porting已經完成了第一次commit ,很大可能會隨著go1.11一併發布第一個版本。
3. 非協作式的goroutine搶佔式調度
當前goroutine的“搶佔式”調度依靠的是compiler在函數中自動插入的“cooperative preemption point”來實現的,但這種方式在使用過程中依然有各種各樣的問題,比如:檢查點的效能損耗、詭異的全面延遲問題以及調試上的困難。近期負責go runtime gc設計與實現的Austin Clements提出了一個proposal:non-cooperative goroutine preemption ,該proposal將去除cooperative preemption point,而改為利用構建和記錄每條指令的stack和register map的方式實現goroutine的搶佔, 該proposal預計將在go 1.12中實現。
4. Go的曆史與未來
在GopherConRu 2018大會上,來自Go team的核心成員Brad Fitzpatrick做了“Go的曆史與未來”的主題演講 ,Bradfitz“爆料”了關於Go2的幾個可能,考慮到Bradfitz在Go team中的位置,這些可能性還是具有很大可信度的:
1). 絕不像Perl6和Python3那樣分裂社區2). Go1的包可以import Go2的package3). Go2很可能加入Generics,Ian Lance Taylor應該在主導該Proposal4). Go2在error handling方面會有改進,但不會是try--catch那種形式5). 相比於Go1,Go2僅會在1-3個方面做出重大變化6). Go2可能會有一個新的標準庫,並且該標準庫會比現有的標準庫更小,很多功能放到標準庫外面7). 但Go2會在標準庫外面給出最流行、推薦的、可能認證的常用包列表,這些在標準庫外面的包可以持續更新,而不像那些在標準庫中的包,只能半年更新一次。
一. 語言篇
1. len(channel)的使用
len是Go語言的一個built-in函數,它支援接受array、slice、map、string、channel類型的參數,並返回對應類型的”長度” – 一個整型值:
len(s) 如果s是string,len(s)返回字串中的位元組個數如何s是[n]T, *[n]T的數群組類型,len(s)返回數組的長度n如果s是[]T的Slice類型,len(s)返回slice的當前長度如果s是map[K]T的map類型,len(s)返回map中的已定義的key的個數如果s是chan T類型,那麼len(s)返回當前在buffered channel中排隊(尚未讀取)的元素個數
不過我們在代碼經常見到的是len函數針對數組、slice、string類型的調用,而len與channel的聯合使用卻很少。那是不是說len(channel)就不可用了呢?我們先來看看len(channel)的語義。
- 當channel為unbuffered channel時,len(channel)總是返回0;
- 當channel為buffered channel時,len(channel)返回當前channel中尚未被讀取的元素個數。
這樣一來,所謂len(channel)中的channel就是針對buffered channel。len(channel)從語義上來說一般會被用來做“判滿”、”判有”和”判空”邏輯:
// 判空if len(channel) == 0 { // 這時:channel 空了 ?}// 判有if len(channel) > 0 { // 這時:channel 有資料了 ?}// 判滿if len(channel) == cap(channel) { // 這時: channel 滿了 ?}
大家看到了,我在上面代碼中注釋:“空了”、“有資料了”和“滿了”的後面打上了問號!channel多用於多個goroutine間的通訊,一旦多個goroutine共同讀寫channel,len(channel)就會在多個goroutine間形成”競態條件”,單存的依靠len(channel)來判斷隊列狀態,不能保證在後續真正讀寫channel的時候channel狀態是不變的。以判空為例:
從可以看到,當goroutine1使用len(channel)判空後,便嘗試從channel中讀取資料。但在真正從Channel讀資料前,另外一個goroutine2已經將資料讀了出去,goroutine1後面的讀取將阻塞在channel上,導致後面邏輯的失效。因此,為了不阻塞在channel上,常見的方法是將“判空與讀取”放在一起做、將”判滿與寫入”一起做,通過select實現操作的“事務性”:
//writing-go-code-issues/3rd-issue/channel_len.go/channel_len.go.gofunc readFromChan(ch <-chan int) (int, bool) { select { case i := <-ch: return i, true default: return 0, false // channel is empty }}func writeToChan(ch chan<- int, i int) bool { select { case ch <- i: return true default: return false // channel is full }}
我們看到由於用到了Select-default的trick,當channel空的時候,readFromChan不會阻塞;當channel滿的時候,writeToChan也不會阻塞。這種方法也許適合大多數的場合,但是這種方法有一個“問題”,那就是“改變了channel的狀態”:讀出了一個元素或寫入了一個元素。有些時候,我們不想這麼做,我們想在不改變channel狀態下單純地偵測channel狀態!很遺憾,目前沒有哪種方法可以適用於所有場合。但是在特定的情境下,我們可以用len(channel)實現。比如下面這個情境:
這是一個“多producer + 1 consumer”的情境。controller是一個總控協程,初始情況下,它來判斷channel中是否有訊息。如果有訊息,它本身不消費“訊息”,而是建立一個consumer來消費訊息,直到consumer因某種情況退出,控制權再回到controller,controller不會立即建立new consumer,而是等待channel下一次有訊息時才建立。在這樣一個情境中,我們就可以使用len(channel)來判斷是否有訊息。
2. 時間的格式化輸出
時間的格式化輸出是日常編程中經常遇到的“題目”。以前使用C語言編程時,用的是strftime。我們來回憶一下c的代碼:
// writing-go-code-issues/3rd-issue/time-format/strftime_in_c.c#include <stdio.h>#include <time.h>int main() { time_t now = time(NULL); struct tm *localTm; localTm = localtime(&now); char strTime[100]; strftime(strTime, sizeof(strTime), "%Y-%m-%d %H:%M:%S", localTm); printf("%s\n", strTime); return 0;}
這段c代碼輸出結果是:
2018-04-04 16:07:00
我們看到strftime採用“字元化”的預留位置(諸如:%Y、%m等)“拼”出時間的目標輸出格式布局(如:”%Y-%m-%d %H:%M:%S”),這種方式不僅在C中採用,很多其他主流程式設計語言也採用了該方案,比如:shell、python、ruby、java等,這似乎已經成為了各種程式設計語言在時間格式化輸出的標準。這些預留位置對應的字元(比如Y、M、H)是對應英文單詞的頭母,因此相對來說較為容易記憶。
但是如果你在Go中使用strftime的這套“標準”,看到輸出結果的那一刻,你肯定要“罵娘”!
// writing-go-code-issues/3rd-issue/time-format/timeformat_in_c_way.gopackage mainimport ( "fmt" "time")func main() { fmt.Println(time.Now().Format("%Y-%m-%d %H:%M:%S"))}
上述go代碼輸出結果如下:
%Y-%m-%d %H:%M:%S
Go居然將“時間格式預留位置字串”原封不動的輸出了!
這是因為Go另闢了蹊徑,採用了不同於strftime的時間格式化輸出的方案。Go的設計者主要出於這樣的考慮:雖然strftime的單個預留位置使用了對應單詞的首字母的形式,但是但真正寫起代碼來,不開啟strftime函數的manual或查看網頁版的strftime助記符說明,很難真的拼出一個複雜的時間格式。並且對於一個”%Y-%m-%d %H:%M:%S”的格式串,不對照文檔,很難在大腦中準確給出格式化後的時間結果,比如%Y和%y有何不同、%M和%m又有何差別呢?
Go語言採用了更為直觀的“參考時間(reference time)”替代strftime的各種標準預留位置,使用“參考時間”構造出來的“時間格式串”與最終輸出串是“一模一樣”的,這就省去了程式員再次在大腦中對格式串進行解析的過程:
格式串:"2006年01月02日 15時04分05秒"=>輸出結果:2018年04月04日 18時13分08秒
標準的參考時間如下:
2006-01-02 15:04:05 PM -07:00 Jan Mon MST
這個絕對時間本身並沒有什麼實際意義,僅是出於“好記”的考慮,我們將這個參考時間換為另外一種時間輸出格式:
01/02 03:04:05PM '06 -0700
我們看出Go設計者的“用心良苦”,這個時間其實恰好是將助記符從小到大排序(從01到07)的結果,可以理解為:01對應的是%M, 02對應的是%d等等。下面這幅圖形象地展示了“參考時間”、“格式串”與最終格式化的輸出結果之間的關係:
就我個人使用go的經曆來看,我在做時間格式化輸出時,尤其是構建略微複雜的時間格式輸出時,也還是要go doc time包或開啟time包的web手冊的。從社區的反饋來看,很多Gopher也都有類似經曆,尤其是那些已經用慣了strftime格式的gopher。甚至有人專門做了“Fucking Go Date Format”頁面,來協助自動將strftime使用的格式轉換為go time的格式。
下面這幅cheatsheet也能提供一些協助(由writing-go-code-issues/3rd-issue/time-format/timeformat_cheatsheet.go輸出產生):
二. 庫與工具篇
1. golang.org/x/text/encoding/unicode遇坑一則
在gocmpp這個項目中,我用到了unicode字元集轉換:將utf8轉換為ucs2(utf16)、ucs2轉換為utf8、utf8轉為GB18030等。這些轉換功能,我是藉助golang.org/x/text這個項目下的encoding/unicode和transform實現的。x/text是golang官方維護的text處理的工具包,其中包含了對unicode字元集的相關操作。
要實現一個utf8到ucs2(utf16)的字元集轉換,只需像如下這樣實現即可(這也是我的最初實現):
func Utf8ToUcs2(in string) (string, error) { if !utf8.ValidString(in) { return "", ErrInvalidUtf8Rune } r := bytes.NewReader([]byte(in)) //UTF-16 bigendian, no-bom t := transform.NewReader(r, unicode.All[1].NewEncoder()) out, err := ioutil.ReadAll(t) if err != nil { return "", err } return string(out), nil}
這裡要注意是unicode.All這個切片儲存著UTF-16的所有格式:
var All = []encoding.Encoding{ UTF16(BigEndian, UseBOM), UTF16(BigEndian, IgnoreBOM), UTF16(LittleEndian, IgnoreBOM),}
這裡我最初我用的是All[1],即UTF16(BigEndian, IgnoreBOM),一切都是正常的。
但就在年前,我將text項目更新到最新版本,然後發現單元測試無法通過:
--- FAIL: TestUtf8ToUcs2 (0.00s) utils_test.go:58: The first char is fe, not equal to expected 6cFAILFAIL github.com/bigwhite/gocmpp/utils 0.008s
經尋找發現:text項目的golang.org/x/text/encoding/unicode包做了不相容的修改,上面那個unicode.All切片變成了下面這個樣子:
// All lists a configuration for each IANA-defined UTF-16 variant.var All = []encoding.Encoding{ UTF8, UTF16(BigEndian, UseBOM), UTF16(BigEndian, IgnoreBOM), UTF16(LittleEndian, IgnoreBOM),}
All切片在最前面插入了一個UTF8元素,這樣導致My Code中原本使用的 UTF16(BigEndian, IgnoreBOM)變成了UTF16(BigEndian, UseBOM),test不過也就情有可原了。
如何改呢?這回兒我直接使用UTF16(BigEndian, IgnoreBOM),而不再使用All切片了:
func Utf8ToUcs2(in string) (string, error) { if !utf8.ValidString(in) { return "", ErrInvalidUtf8Rune } r := bytes.NewReader([]byte(in)) //UTF-16 bigendian, no-bom t := transform.NewReader(r, unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM).NewEncoder()) out, err := ioutil.ReadAll(t) if err != nil { return "", err } return string(out), nil}
這樣即便All切片再有什麼變動,My Code也不會受到什麼影響了。
2. logrus的非結構化日誌定製輸出
在該系列的第一篇文章中,我提到過使用logrus+lumberjack來實現支援rotate的logging。
預設情況下日誌的輸出格式是這樣的(writing-go-code-issues/3rd-issue/logrus/logrus2lumberjack_default.go):
time="2018-04-05T06:08:53+08:00" level=info msg="logrus log to lumberjack in normal text formatter"
這樣相對結構化的日誌比較適合後續的集中日誌分析。但是日誌攜帶的“元資訊(time、level、msg)”過多,並不是所有場合都傾向於這種日誌,於是我們期望以普通的非結構化的日誌輸出,我們定製formatter:
// writing-go-code-issues/3rd-issue/logrus/logrus2lumberjack.gofunc main() { customFormatter := &logrus.TextFormatter{ FullTimestamp: true, TimestampFormat: "2006-01-02 15:04:05", } logger := logrus.New() logger.Formatter = customFormatter rotateLogger := &lumberjack.Logger{ Filename: "./foo.log", } logger.Out = rotateLogger logger.Info("logrus log to lumberjack in normal text formatter")}
我們使用textformatter,並定製了時間戳記的格式,輸出結果如下:
time="2018-04-05 06:22:57" level=info msg="logrus log to lumberjack in normal text formatter"
日誌仍然不是我們想要的那種。但同樣的customFormatter如果輸出到terminal,結果卻是我們想要的:
//writing-go-code-issues/3rd-issue/logrus/logrus2tty.goINFO[2018-04-05 06:26:16] logrus log to tty in normal text formatter
到底如何設定TextFormatter的屬性才能讓我們輸出到lumberjack中的日誌格式是我們想要的這種呢?無奈下只能挖logrus的源碼了,我們找到了這段代碼:
//github.com/sirupsen/logrus/text_formatter.go// Format renders a single log entryfunc (f *TextFormatter) Format(entry *Entry) ([]byte, error) { ... ... isColored := (f.ForceColors || f.isTerminal) && !f.DisableColors timestampFormat := f.TimestampFormat if timestampFormat == "" { timestampFormat = defaultTimestampFormat } if isColored { f.printColored(b, entry, keys, timestampFormat) } else { if !f.DisableTimestamp { f.appendKeyValue(b, "time", entry.Time.Format(timestampFormat)) } f.appendKeyValue(b, "level", entry.Level.String()) if entry.Message != "" { f.appendKeyValue(b, "msg", entry.Message) } for _, key := range keys { f.appendKeyValue(b, key, entry.Data[key]) } } b.WriteByte('\n') return b.Bytes(), nil}
我們看到如果isColored為false,輸出的就是帶有time, msg, level的結構化日誌;只有isColored為true才能輸出我們想要的普通日誌。isColored的值與三個屬性有關:ForceColors 、isTerminal和DisableColors。我們按照讓isColored為true的條件組合重新設定一下這三個屬性,因為輸出到file,因此isTerminal自動為false。
//writing-go-code-issues/3rd-issue/logrus/logrus2lumberjack_normal.gofunc main() { // isColored := (f.ForceColors || f.isTerminal) && !f.DisableColors customFormatter := &logrus.TextFormatter{ FullTimestamp: true, TimestampFormat: "2006-01-02 15:04:05", ForceColors: true, } logger := logrus.New() logger.Formatter = customFormatter rotateLogger := &lumberjack.Logger{ Filename: "./foo.log", } logger.Out = rotateLogger logger.Info("logrus log to lumberjack in normal text formatter")}
我們設定ForceColors為true後,在foo.log中得到了我們期望的輸出結果:
INFO[2018-04-05 06:33:22] logrus log to lumberjack in normal text formatter
三. 實踐篇
1. 說說網路資料讀取timeout的處理 – 以SetReadDeadline為例
Go天生適合於網路編程,但網路編程的複雜性也是有目共睹的、要寫出穩定、高效的網路端程式,需要的考慮的因素有很多。比如其中之一的:從socket讀取資料逾時的問題。
Go語言標準網路程式庫並沒有實現epoll實現的那樣的“idle timeout”,而是提供了Deadline機制,我們用一副圖來對比一下兩個機制的不同:
看a)和b)展示了”idle timeout”機制,所謂idle timeout就是指這個timeout是真正在沒有data ready的情況的timeout(中a),如果有資料ready可讀(中b),那麼timeout機制暫停,直到資料讀完後,再次進入資料等待的時候,idle timeout再次啟動。
而deadline(以read deadline為例)機制,則是無論是否有資料ready以及資料讀取活動,都會在到達時間(deadline)後的再次read時返回timeout error,並且後續的所有network read operation也都會返回timeout(中d),除非重新調用SetReadDeadline(time.Time{})取消Deadline或在再次讀取動作前重新重新設定deadline實現續時的目的。Go網路編程一般是“阻塞模型”,那為什麼還要有SetReadDeadline呢,這是因為有時候,我們要給調用者“感知”其他“異常情況”的機會,比如是否收到了main goroutine發送過來的退出通知資訊。
Deadline機制在使用起來很容易出錯,這裡列舉兩個曾經遇到的出錯狀況:
a) 以為SetReadDeadline後,後續每次Read都可能實現idle timeout
在中,我們看到這個流程是讀取一個完整業務包的過程,業務包的讀取使用了三次Read調用,但是只在第一次Read前調用了SetReadDeadline。這種使用方式僅僅在Read A時實現了足額的“idle timeout”,且僅當A資料始終未ready時會timeout;一旦A資料ready並已經被Read,當Read B和Read C時,如果還期望足額的“idle timeout”那就誤解了SetReadDeadline的真正含義了。因此要想在每次Read時都實現“足額的idle timeout”,需要在每次Read前都重新設定deadline。
b) 一個完整“業務包”分多次讀取的異常情況的處理
在這幅圖中,每個Read前都重新設定了deadline,那麼這樣就一定ok了嗎?對於在一個過程中讀取一個“完整業務包”的商務邏輯來說,我們還要考慮對每次讀取異常情況的處理,尤其是timeout發生。在該例子中,有三個Read位置需要考慮異常處理。
如果Read A始終沒有讀到資料,deadline到期,返回timeout,這裡是最容易處理的,因為此時前一個完整資料包已經被讀完,新的完整資料包還沒有到來,外層控制邏輯收到timeout後,重啟再次啟動該讀流程即可。
如果Read B或Read C處沒有讀到資料,deadline到期,這時異常處理就棘手一些,因為一個完整資料包的部分資料(A)已經從流中被讀出,剩餘的資料並不是一個完整的業務資料包,不能簡單地再在外層控制邏輯中重新啟動該過程。我們要麼在Read B或Read C處嘗試多次重讀,直到將完整資料包讀取完整後返回;要麼認為在B或C處出現timeout是不合理的,返回區別於A處的錯誤碼給外層控制邏輯,讓外層邏輯決定是否是串連存在異常。
註:本文所涉及的範例程式碼,請到這裡下載。
微博:@tonybai_cn
公眾號:iamtonybai
github.com: https://github.com/bigwhite
讚賞:
2018, bigwhite. 著作權.