這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
之前我們介紹了io包和協議解析,這次我們要來講講bufio包,這個包實現了在項目中很常用到的帶緩衝的IO。先從我們前一個小貼士中的分包代碼講起,重新貼一下這段代碼:
func ReadPacket(conn net.Conn) ([]byte, error) { var head [2]byte if _, err := io.ReadFull(conn, head[:]); err != nil { return err } size := binary.BigEndian.Uint16(head) packet := make([]byte, size) if _, err := io.ReadFull(conn, packet); err != nil { return err } return packet}
這個分包邏輯,對conn執行了兩次io.ReadFull調用,從小貼士1對io包的介紹中,大家可以知道io.ReadFull實際上是一個內部迴圈調用conn.Read()的過程,所以這段代碼雖然很短,但是潛在的IO調用次數卻挺多,在最理想情況下,也至少要調用兩次conn.Read()。
IO調用的開銷是什麼呢?這得從Go的runtime實現分析起,假設我們這裡用到的是一個TCP串連,從TCPConn.Read()為入口,我們可以定位到fd_unix.go這個檔案中的netFD.Read()方法。
這個方法中有一個迴圈調用syscall.Read()和pd.WaitRead()的過程,這個過程有兩個主要開銷。
首先是syscall.Read()的開銷,這個系統調用會在應用程式緩衝區和系統的Socket緩衝區之間複製資料。
其次是pd.WaitRead(),因為Go的核心是CSP模型,要讓一個線程上可以跑多個Goroutine,其中的關鍵就是讓需要等待IO的Goroutine讓出執行線程,當IO事件到達的時候再重新喚醒Goroutine,這樣來回切換是有一定開銷的。
而我們的這個分包協議的包頭很小,有極大的機率是包頭和一部分包體甚至是整個包已經在Socket緩衝區等待我們讀取,這種情況就很適合使用bufio.Reader來最佳化效能。
bufio.Reader的基本工作原理是使用一塊預先分配好的記憶體作為緩衝區,發生真實IO的時候盡量填充緩衝區,調用者讀取資料的時候先從緩衝區中讀取,從而減少真實的IO調用次數,以起到最佳化作用。
舉一個形象點的例子:你有一個不能移動的桶,一個杯子和一個水龍頭(老式的,流量小,手擰開關),你要往桶裡裝滿水並且不能讓水龍頭的水白白流走。這時候就需要拿著杯子在水龍頭下接水,接滿一杯立即關掉水龍頭,把杯裡水倒進桶裡,再回來開水龍頭接水,如此往複直到桶滿。這個過程中很多時間浪費在開關水龍頭和等杯子裝滿水。
如果這時候拿個桶放在水龍頭下,水龍頭就不用關了,每次先到水龍頭下的桶裡舀一杯水,如果桶裡沒水才去水龍頭接,這樣就省掉了開關水龍頭和等杯子裝滿水的時間。bufio.Reader做的就是這樣一個事情。
把一個io.Reader封裝成bufio.Reader只需要一行代碼,我們的代碼可以改造成以下形式:
type PacketConn struct { net.Conn reader *bufio.Reader}func NewPacketConn(conn net.Conn) *PacketConn { return &PacketConn{conn, bufio.NewReader(conn)}}func (conn *PacketConn) ReadPacket() []byte { var head [2]byte if _, err := io.ReadFull(conn.reader, head[:]); err != nil { return err } size := binary.BigEndian.Uint16(head) packet := make([]byte, size) if _, err := io.ReadFull(conn.reader, packet); err != nil { return err } return packet}func (conn *PacketConn) Read(p []byte) (int, error) { return conn.reader.Read(p)}
代碼邏輯是一樣的,但是因為用了bufio.Reader,在理想狀態下,ReadPacket在第一次io.ReadFull調用的時候就會把後續的資料讀入緩衝區,第二次io.ReadFull不會有真實IO調用產生。
這裡有一個細節需要注意,一旦有一個io.Reader被bufio.Reader封裝並使用了以後,要從這個io.Reader讀取資料就需要從同一個bufio.Reader讀取,不能一會用原生io.Reader一會用bufio.Reader,也不能分別從兩個bufio.Reader讀取,因為每次讀取都有可能緩衝一部分後續資料在緩衝中,如果下次讀取不是從緩衝區裡的資料開始讀,那麼讀到的資料意義就不一樣了。
除了我們的這個二進位分包協議可以利用bufio.Reader來最佳化效能之外,文本協議的解析可以說幾乎無法不適用bufio.Reader。
我們舉個簡單的文本協議例子,假設我們有個簡單的文本協議是用'\n'分行符號來作為一行資料的結尾,一行一行的發送文本資料。
我們在不使用bufio.Reader的情況下要怎樣從io.Reader中一行一行的讀取資料呢?
顯然,我們會需要寫一個迴圈(虛擬碼,沒編譯):
func ReadLine(reader io.Reader) (line []byte, err error) { var p = []byte{0} for { _, err := reader.Read(p) if err == io.EOF { return line, err } if err != nil { return nil, err } if p[0] == '\n' { return line, nil } line = append(line, p[0]) }}
逐位元組的調用Read方法顯然效率會極低,所以顯然這裡需要用一個緩衝區來預讀和解析以及緩衝殘餘資料。
這種情況很常見,比如HTTP協議就是一個機遇換行的文本協議,所以bufio.Reader直接就內建了ReadLine等一些列用於文本協議解析的方法。
如果bufio.Reader還無法滿足你的複雜協議解析需求,bufio還另外提供了Scanner來實現自訂的格式解析。
bufio包還提供了一個Writer類型,用於實現帶緩衝區的寫入,比如HTTP應用在輸入一個HTML頁面的時候,經常會分多個步驟輸出HTML的常值內容,如果每次輸出都真實發生一次IO調用,效率顯然會很不好,先寫入緩衝區,再一次性發送給用戶端,這樣就可以大量減少IO調用次數了。
本文無法替代bufio包的文檔對所有內容一一做說明,更多內容請大家進一步閱讀bufio包的文檔。