動態

詳情 返回 返回

Go 語言中你不知道的 io.Discard 妙用 - 動態 詳情

公眾號首發:https://mp.weixin.qq.com/s/no995DjgiS3muyPSt2QhUg

在 Go 語言中,io.Discard 是一個實現了 io.Writer 接口的特殊變量,用於丟棄所有寫入的數據。 io.Discard 在 Go 1.15 及之前版本中是放在 io/ioutil 包中實現的。而在 Go 1.16 版本,得以正式轉正,被實現在 io 包中。本文我們來一起學習下 io.Discard 的實現及使用場景。

使用示例

假如我們有一個 Web Server 程序,它提供了 HTTP Get 請求的健康檢查接口 /healthz,調用後返回 HTTP 200 狀態碼和類似 {"timestamp":"2025-04-17 23:42:15"} 格式的 Body 數據。

其實大多數情況下,我們想檢查這個 Web Server 是否健康,只需檢查 /healthz 接口響應的狀態碼為 200 即可,而不必關心返回內容。

如果使用 Go 程序來做健康檢查,那麼就可以使用 io.Discard 丟棄響應體內容,實現代碼如下:

func healthCheck() {
    // 發送健康檢查請求
    resp, err := http.Get("http://127.0.0.1:5555/healthz")
    if err != nil {
        panic(fmt.Sprintf("請求失敗: %v", err))
    }
    defer resp.Body.Close() // 確保連接回收

    // 丟棄響應體
    _, _ = io.Copy(io.Discard, resp.Body)

    // 狀態碼校驗
    if resp.StatusCode != http.StatusOK {
        panic(fmt.Sprintf("非預期狀態碼: %d", resp.StatusCode))
    }

    fmt.Println("健康檢查通過")
}

這裏使用了 io.Copy(io.Discard, resp.Body) 將響應體內容複製到 io.Discard 進行丟棄。你可能會問,不丟棄 resp.Body 會有什麼後果嗎?

如果不丟棄 resp.Body,那麼 defer resp.Body.Close() 還是能夠確保 resp.Body 被正確關閉,不會導致資源泄露。

不過,如果我們主動清空 resp.Body 的內容,則會帶來一個額外的好處,會使當前這個 HTTP 請求的底層 TCP 連接能夠被複用,從而更加有效的利用資源。

如下是 http.Get(url) 這個函數調用流程中涉及的核心代碼:

func Get(url string) (resp *Response, err error) {
    return DefaultClient.Get(url)
}

func (c *Client) Get(url string) (resp *Response, err error) {
    req, err := NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }
    return c.Do(req)
}

func (c *Client) Do(req *Request) (*Response, error) {
    return c.do(req)
}

func (c *Client) do(req *Request) (retres *Response, reterr error) {
    ...

            // Close the previous response's body. But
            // read at least some of the body so if it's
            // small the underlying TCP connection will be
            // re-used. No need to check for errors: if it
            // fails, the Transport won't reuse it anyway.
            const maxBodySlurpSize = 2 << 10
            if resp.ContentLength == -1 || resp.ContentLength <= maxBodySlurpSize {
                io.CopyN(io.Discard, resp.Body, maxBodySlurpSize)
            }
            resp.Body.Close()
    ...
}

請求調用流程是:http.Get(url) => DefaultClient.Get(url) => c.Do(req) => c.do(req)

c.do 方法中,有這麼一段註釋值得我們注意:

// Close the previous response's body. But
// 關閉前一個響應的 body。但
// read at least some of the body so if it's
// 至少讀取部分響應體數據,這樣當響應體較小時
// small the underlying TCP connection will be
// 底層 TCP 連接會被複用。
// re-used. No need to check for errors: if it
// 無需檢查錯誤:若操作失敗,
// fails, the Transport won't reuse it anyway.
// Transport 也不會複用該連接。

也就是説當我們調用 http.Get(url) 發起 HTTP 請求後,如果操作失敗或未讀取 Body,那麼 Transport 是不會複用 TCP 連接的。如果讀取 Body 後再關閉連接,那麼 Transport 就會複用底層 TCP 連接。

結合這段註釋和代碼,可以發現,其實 http.Get(url) 內部也會使用 io.CopyN(io.Discard, resp.Body, maxBodySlurpSize) 這種形式丟棄響應體,從而複用 TCP 連接。

看來 io.Discard 還是一個比較有用的工具。

源碼解讀

你是否會好奇 io.Discard 是如何實現的呢?

其實它的實現非常簡單,接下來,我們就從源碼的角度來更加深入的學習 io.Discard

Go 1.15 及之前版本

我在前文中説過 io.Discard 在 Go 1.15 及之前版本中,是存放在 io/ioutil 包的,其代碼如下:

https://github.com/golang/go/blob/go1.15.15/src/io/ioutil/ioutil.go#L158
type devNull int

// devNull implements ReaderFrom as an optimization so io.Copy to
// ioutil.Discard can avoid doing unnecessary work.
var _ io.ReaderFrom = devNull(0)

// Write 實現 io.Writer 接口
func (devNull) Write(p []byte) (int, error) {
    return len(p), nil
}

// WriteString 實現 io.StringWriter 接口
func (devNull) WriteString(s string) (int, error) {
    return len(s), nil
}

var blackHolePool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 8192)
        return &b
    },
}

// ReadFrom 實現 io.ReadFrom 接口
func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
    bufp := blackHolePool.Get().(*[]byte)
    readSize := 0
    for {
        readSize, err = r.Read(*bufp)
        n += int64(readSize)
        if err != nil {
            blackHolePool.Put(bufp)
            if err == io.EOF {
                return n, nil
            }
            return
        }
    }
}

// Discard is an io.Writer on which all Write calls succeed
// without doing anything.
var Discard io.Writer = devNull(0)

通過 var Discard io.Writer = devNull(0) 這行代碼我們知道,io.Discard 是一個變量,並且實現了 io.Writer 接口,其類型為 devNull,而 devNull 的底層類型是 int 類型。

從 Go 1.15 版本中 io.Discard 的類型名稱可以看出,其對標的正是 Linux/Unix 系統中的 /dev/null。在 Linux/Unix 系統中,/dev/null 是一個“黑洞”,用於實現靜默丟棄數據。它是一個特殊的設備文件,向它寫入的任何數據都會被系統所丟棄,讀取它時則立即返回文件結束(EOF)。

如下是一個在 Makefile 命令中使用 /dev/null 的示例。

https://github.com/onexstack/miniblog/blob/master/scripts/make-rules/golang.mk#L32
go.build.verify:
    @if ! which go &>/dev/null; then echo "Cannot found go compile tool. Please install go tool first."; exit 1; fi

這裏使用 &>/dev/nullwhich go 執行的輸出進行丟棄。

當執行 make go.build.verify 時,如果在系統中未安裝 Go,則會報錯並以狀態碼 1 退出,輸出 Cannot found go compile tool. Please install go tool first.,如果安裝了 Go,則正常退出。

NOTE:

此 Makefile 代碼片段來源於 onexstack 技術棧中的開源項目 miniblog,miniblog 是一個小而美的 Go 實戰項目,入門但不簡單。

你可以在這裏 https://t.zsxq.com/1979HJ45x 看到 miniblog 項目的教程。

Go 語言中 io.Discard 的行為正是模擬了 /dev/null 的特性。

io.Discard 實現的 io.Writer 接口非常簡單,在 Write 方法中什麼也沒做,直接返回了 len(p), nil,即寫入數據長度和無任何錯誤。io.Discard 就是以這麼樸素的方式實現了數據的丟棄。

io.Discard 實現的 WriteString 方法同樣如此,此方法用來實現 io.StringWriter 接口。

此外,io.Discard 還實現了 io.ReadFrom 接口,實現這個接口的方法內部邏輯不再只有一行代碼,不過我們先不急着研究它,放在下一小結講解 Go 1.16 及之後版本的 io.Discard 源碼部分。

Go 1.16 及之後版本

在 Go 1.16 及之後版本中 io.Discard 的實現遷移到了 io 中,其代碼如下:

https://github.com/golang/go/blob/go1.24.0/src/io/io.go#L639
// Discard 是一個 [io.Writer] 接口的實現,所有寫入調用都會成功但不會執行任何實際操作
var Discard Writer = discard{}

// discard 是一個空結構體實現
type discard struct{}

// discard 實現了 io.ReaderFrom 接口作為性能優化手段,使得通過 io.Copy 向 io.Discard 拷貝數據時能避免冗餘操作
var _ ReaderFrom = discard{}

// Write 實現 io.Writer 接口
func (discard) Write(p []byte) (int, error) {
    return len(p), nil
}

// WriteString 實現 io.StringWriter 接口
func (discard) WriteString(s string) (int, error) {
    return len(s), nil
}

// 使用 sync.Pool 構建緩衝池
var blackHolePool = sync.Pool{
    New: func() any {
        b := make([]byte, 8192) // 8KB 緩衝池
        return &b               // 返回指針對象
    },
}

// ReadFrom 實現 io.ReadFrom 接口
func (discard) ReadFrom(r Reader) (n int64, err error) {
    bufp := blackHolePool.Get().(*[]byte) // 從池中獲取緩衝區
    readSize := 0
    for {
        readSize, err = r.Read(*bufp) // 讀取數據到緩衝區
        n += int64(readSize)          // 累加丟棄的字節數
        if err != nil {
            blackHolePool.Put(bufp) // 用後歸還緩衝池
            if err == EOF {         // 正常結束
                return n, nil
            }
            return // 異常出錯
        }
    }
}

io.Discard 的主要邏輯並沒有改變,有趣的是,它從之前的 devNull 類型改成了 discard 類型。devNull 底層是 int 類型,而 discard 則是空結構體。至於為什麼要改成空結構體,你可以在我的文章《Go 中空結構體慣用法,我幫你總結全了!》中找到答案。

在這裏,我詳細解釋下 ReadFrom 方法的實現。

首先,這裏使用 sync.Pool 構建了一個緩衝池 blackHolePoolsync.PoolNew 方法返回一個 8KB 大小的緩衝對象,當我們調用 blackHolePool.Get() 時,就可以從緩衝池中獲取對象 &b。所以 bufp 是通過 sync.Pool 分配的 8KB 固定長度切片(make([]byte, 8192))。通過 sync.Pool 複用 8KB 切片,避免頻繁內存分配,能夠降低 Go GC 的壓力。

注意:

在使用 sync.Pool 時,New 屬性所對應的構造函數應該返回指針類型對象 &b 這在 sync.Pool 官方示例中也有提到 https://pkg.go.dev/sync@go1.24.0#example-Pool

接着,在 for 循環中,每次調用 r.Read(*bufp) 時,數據會從 bufp 切片的起始位置開始填充(這會覆蓋前一次循環中緩衝區的內容)。這裏會累加丟棄的字節數,用於最終返回。

對於 bufp 對象,用後需要歸還到緩衝池中,以便下次使用,所以需要調用 sync.Pool 對象的 Put 方法進行歸還。

如果 r.Read 返回 EOF 則終止循環,返回累計丟棄的字節數 n。如果返回其他類型錯誤,則直接返回 err 和已經讀取的字節數。

io.Discard 之所以要實現 ReadFrom 方法,其實是為了支持與 io.Copy 協同工作。

當使用 io.Copy(io.Discard, src) 丟棄數據時,io.Copy 會優先調用 ReadFrom 方法。

io.Copy 實現源碼如下:

https://github.com/golang/go/blob/go1.24.0/src/io/io.go#L387
func Copy(dst Writer, src Reader) (written int64, err error) {
    return copyBuffer(dst, src, nil)
}

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rf, ok := dst.(ReaderFrom); ok {
        return rf.ReadFrom(src)
    }
    if buf == nil {
        size := 32 * 1024
        if l, ok := src.(*LimitedReader); ok && int64(size) > l.N {
            if l.N < 1 {
                size = 1
            } else {
                size = int(l.N)
            }
        }
        buf = make([]byte, size)
    }
    for {
        nr, er := src.Read(buf)
        if nr > 0 {
            nw, ew := dst.Write(buf[0:nr])
            if nw < 0 || nr < nw {
                nw = 0
                if ew == nil {
                    ew = errInvalidWrite
                }
            }
            written += int64(nw)
            if ew != nil {
                err = ew
                break
            }
            if nr != nw {
                err = ErrShortWrite
                break
            }
        }
        if er != nil {
            if er != EOF {
                err = er
            }
            break
        }
    }
    return written, err
}

io.Copy 的核心實現邏輯遵循 “優先調用高效接口 => 動態適配緩衝區 => 循環讀寫兜底” 的分層策略。其核心步驟如下:

  1. 如果 src 對象實現了 WriterTo 接口(如 *os.File*bytes.Buffer 等),則直接調用 wt.WriteTo(dst) 將數據寫入 dst,這樣可以利用系統底層的零拷貝機制。
  2. 如果 dst 對象實現了 ReaderFrom 接口(如 *os.File*net.TCPConn 等),則直接調用 rf.ReadFrom(src)src 讀取內容到 dst,讓目標對象自主控制數據讀取邏輯。
  3. 默認緩衝區大小設為 32KB,如果 src 對象是 *LimitedReader 類型,則動態調整緩衝區大小,避免內存浪費。
  4. 最終實現循環讀寫邏輯,調用 src.Read(buf)src 讀取數據到 buf,然後調用 dst.Write(buf[0:nr])buf 將數據寫入 dst

io.Copy 內部之所以考慮了這麼多種場景,正是為了靈活性和性能優化所考慮的,比如 *os.File.WriterTo 方法實現了零拷貝,這樣可以將性能優化到極致。

io.Copy 設計思想可以總結為下表:

層級 優化目標 技術手段 性能
接口優先 零拷貝、減少內存操作 檢查是否實現 WriterTo/ReaderFrom 接口
緩衝區動態適配 平衡內存與系統調用開銷 按需分配緩衝區大小
循環讀寫兜底 通用性、錯誤處理完備性 默認緩衝區大小,循環讀寫

那麼 io.Discard 實現 ReadFrom 方法的目的其實也就不言自明瞭,核心目的是通過優化數據讀取和丟棄的流程,提升性能並減少資源消耗。

實踐案例

現在我們學習了 io.Discard 源碼,可以繼續來探索一下,io.Discard 更加真實的實踐案例。

Go 源碼中的應用

首先我們來看一下 io.Discard 在 Go 源碼中如何應用?

我們知道,Go 語言在 os 包中,為我們提供了 os.Stat 函數,用於獲取文件或目錄元數據信息,其作用與 Linux/Unix 系統調用 stat 類似,但設計上更符合 Go 語言的接口規範。

如下是 os.Stat 函數使用示例:

func filesize(name string) (int64, error) {
    fi, err := os.Stat(name)
    if err != nil {
        if os.IsNotExist(err) {
            return 0, errors.New("文件不存在")
        }
        return 0, fmt.Errorf("讀取文件失敗: %w", err)
    }
    return fi.Size(), nil
}

調用 fi = os.Stat(name) 可以通過文件路徑 name 獲取文件系統對象的元數據對象 fi,然後通過 fi.Size() 可以得到文件大小。

os.Stat 用法非常簡單,通常 Go 內置包的函數或方法都會有完整的單元測試,那麼 os.Stat 如何 Go 源碼是如何測試其正確性的呢?測試代碼如下:

https://github.com/golang/go/blob/go1.24.0/src/os/os_test.go#L180
func TestStat(t *testing.T) {
    t.Parallel()

    path := sfdir + "/" + sfname
    dir, err := Stat(path)
    if err != nil {
        t.Fatal("stat failed:", err)
    }
    if !equal(sfname, dir.Name()) {
        t.Error("name should be ", sfname, "; is", dir.Name())
    }
    filesize := size(path, t)
    if dir.Size() != filesize {
        t.Error("size should be", filesize, "; is", dir.Size())
    }
}

這個單元測試用例中調用了 filesize := size(path, t) 來獲取文件大小,然後與 dir.Size() 進行比較,以此來判斷通過 os.Stat 獲取的文件大小是否正確。

而這個 size 函數實現如下:

https://github.com/golang/go/blob/go1.24.0/src/os/os_test.go#L133
func size(name string, t *testing.T) int64 {
    file, err := Open(name)
    if err != nil {
        t.Fatal("open failed:", err)
    }
    defer func() {
        if err := file.Close(); err != nil {
            t.Error(err)
        }
    }()
    n, err := io.Copy(io.Discard, file)
    if err != nil {
        t.Fatal(err)
    }
    return n
}

可以發現,size 函數使用 n, err := io.Copy(io.Discard, file) 來丟棄 file 中的內容,並將返回值 n 作為文件大小。

這便是 io.Discard 包在 Go 源碼中的應用。

Kubernetes 源碼中的應用

接着我們再來看一下 io.Discard 在 Kubernetes 源碼中如何應用?

我們知道,Kubernetes 在 Pod 的生命週期中為容器提供了 postStart 啓動鈎子和 preStop 停止鈎子。比如我們可以像如下方式,在容器創建後,將當前 Pod 運行的服務上報到註冊中心:

lifecycle:
  postStart:
    httpGet:
      path: /register?ip=${POD_IP}&port=8080
      port: 8848  # 註冊中心端口
      host: service.default.svc.cluster.local
      scheme: HTTP

我們可以更進一步,從源碼的角度看一下 postStart 內部的執行邏輯。

為了方便,我們可以從單元測試開始讀起,在 kubelet 源碼中找到 TestRunHandlerHttpsFailureFallback 測試函數:

https://github.com/kubernetes/kubernetes/blob/v1.31.0/pkg/kubelet/lifecycle/handlers_test.go#L727
func TestRunHandlerHttpsFailureFallback(t *testing.T) {
    ...
    handlerRunner := NewHandlerRunner(srv.Client(), &fakeContainerCommandRunner{}, fakePodStatusProvider, recorder).(*handlerRunner)
    ...
    container := v1.Container{
        Name: containerName,
        Lifecycle: &v1.Lifecycle{
            PostStart: &v1.LifecycleHandler{
                HTTPGet: &v1.HTTPGetAction{
                    // set the scheme to https to ensure it falls back to HTTP.
                    Scheme: "https",
                    Host:   "127.0.0.1",
                    Port:   intstr.FromString(port),
                    Path:   "bar",
                    HTTPHeaders: []v1.HTTPHeader{
                        {
                            Name:  "Authorization",
                            Value: "secret",
                        },
                    },
                },
            },
        },
    }
    pod := v1.Pod{}
    ...
    pod.Spec.Containers = []v1.Container{container}
    msg, err := handlerRunner.Run(ctx, containerID, &pod, &container, container.Lifecycle.PostStart)
    ...
}

在這裏,首先通過 NewHandlerRunner 函數構造了一個用來處理容器生命週期函數的 Handler 對象 handlerRunner

接下來創建的 container 中實現了 postStart 鈎子,通過 HTTP Get 方式,訪問 https://127.0.0.1:port/bar 地址。在 Pod 準備就緒後,調用 handlerRunner.Run 方法來處理容器生命週期函數。

Run 方法實現如下:

https://github.com/kubernetes/kubernetes/blob/v1.31.0/pkg/kubelet/lifecycle/handlers.go#L70
func (hr *handlerRunner) Run(ctx context.Context, containerID kubecontainer.ContainerID, pod *v1.Pod, container *v1.Container, handler *v1.LifecycleHandler) (string, error) {
    switch {
    case handler.Exec != nil:
        ...
    case handler.HTTPGet != nil:
        err := hr.runHTTPHandler(ctx, pod, container, handler, hr.eventRecorder)
        var msg string
        if err != nil {
            msg = fmt.Sprintf("HTTP lifecycle hook (%s) for Container %q in Pod %q failed - error: %v", handler.HTTPGet.Path, container.Name, format.Pod(pod), err)
            klog.V(1).ErrorS(err, "HTTP lifecycle hook for Container in Pod failed", "path", handler.HTTPGet.Path, "containerName", container.Name, "pod", klog.KObj(pod))
        }
        return msg, err
    case handler.Sleep != nil:
        ...
    default:
        ...
    }
}

可以看到,如果是 HTTP Get 類型的鈎子,會調用 hr.runHTTPHandler 進一步處理。

runHTTPHandler 方法實現如下:

https://github.com/kubernetes/kubernetes/blob/v1.31.0/pkg/kubelet/lifecycle/handlers.go#L143
func (hr *handlerRunner) runHTTPHandler(ctx context.Context, pod *v1.Pod, container *v1.Container, handler *v1.LifecycleHandler, eventRecorder record.EventRecorder) error {
    ...
    req, err := httpprobe.NewRequestForHTTPGetAction(handler.HTTPGet, container, podIP, "lifecycle")
    if err != nil {
        return err
    }
    resp, err := hr.httpDoer.Do(req)
    discardHTTPRespBody(resp)
    ...
}

這裏通過 httpprobe.NewRequestForHTTPGetAction 構造一個 HTTP 請求對象,然後交給 hr.httpDoer.Do(req) 去執行,接下來重點來了,這裏會調用 discardHTTPRespBody(resp) 函數。根據這個函數的命名,我們也能大概猜測到它是用來幹什麼的。

discardHTTPRespBody 函數實現如下:

https://github.com/kubernetes/kubernetes/blob/v1.31.0/pkg/kubelet/lifecycle/handlers.go#L199
func discardHTTPRespBody(resp *http.Response) {
    if resp == nil {
        return
    }

    // Ensure the response body is fully read and closed
    // before we reconnect, so that we reuse the same TCP
    // connection.
    defer resp.Body.Close()

    if resp.ContentLength <= maxRespBodyLength {
        io.Copy(io.Discard, &io.LimitedReader{R: resp.Body, N: maxRespBodyLength})
    }
}

可以發現,這裏與我在文章開頭説演示的示例思想完全相同,在 defer 語句中關閉 resp.Body,並主動使用 io.Copy(io.Discard, ...) 來丟棄 resp.Body

並且註釋寫的也很明確,確保讀完響應體內容並關閉,能夠實現複用 TCP 連接。

這便是 io.Discard 包在 Kubernetes 源碼中的應用。

總結

io.Discard 雖然是 Go 提供的一個很小的功能點,但其有自己的妙用所在。io.Discard 對標的是 Linux/Unix 系統中的 /dev/null 文件,我們可以使用它來實現靜默丟棄數據。

我帶你分別閲讀了 io.Discard 在 Go 1.15 及之前版本以及在 Go 1.16 及之後版本中的實現。即使是這樣一個小功能點,Go 在升級時也做了優化,使用空結構體替代了 int 的實現。

我還舉了兩個實踐案例,來帶你體會 io.Discard 的真實使用場景。

Go 中的 os 包在單元測試中,利用 io.Discard 來計算文件大小。我們在日常的開發中也可以思考一下在哪些單元測試場景中可以使用 io.Discard

Kubernetes 在 kubelet 中使用 io.Discard 來丟棄 HTTP 響應體,以此達到複用底層 TCP 連接的目的。

你還知道 io.Discard 有哪些用法,歡迎告訴我咱們一起交流討論。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

聯繫我

  • 公眾號:Go編程世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 博客:https://jianghushinian.cn
  • GitHub:https://github.com/jianghushinian

Add a new 評論

Some HTML is okay.