动态

详情 返回 返回

使用 Uber automaxprocs 正確設置 Go 程序線程數 - 动态 详情

公眾號首發地址:https://mp.weixin.qq.com/s/5wrYaHXBpuN0WxKAaNNp-A

我們知道 Go 語言沒有直接對用户暴露線程的概念,而是通過 goroutine 來控制併發。不過,在 Go 程序啓動時,其背後的調度器往往是多線程運行的。在 Go 語言的 GMP 調度模型中,P 決定着同時運行的 goroutine 數,我們可以通過環境變量 GOMAXPROCS 或者運行時函數 runtime.GOMAXPROCS(n) 來設置 P 的數量。默認情況下 P 的數量等於機器中可用的 CPU 核心數,不過,這也帶來了一個潛在的問題,在容器運行環境下,P 的數量可能遠超容器限制的 CPU 數量,從而引發性能問題。本文我們一起來看一下 Go 程序在不同運行環境中的表現,以及如何解決潛在問題。

首先,介紹下本文用來測試程序的電腦是 MacBook Pro Core i5 雙核四線程的 Intel CPU。我將分別在宿主機和容器環境下進行演示。

NOTE:

文中所出現的 GOMAXPROCS 和 P 代表的都是一個意思,即表示 GMP 模型中 P 的數量值。

宿主機環境

我們先來看在宿主機環境下執行 Go 程序,查看 P 數量的表現。

示例代碼如下:

main.go
package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Printf("GOMAXPROCS = %d\n", runtime.GOMAXPROCS(0))
}

runtime.GOMAXPROCS(n) 接收一個 int 類型的參數,如果參數 n 小於等於 0,則返回當前 P 的數量,如果 n 大於 0,則設置 P 的數量為 n 並返回修改前 P 的數量。

執行示例代碼,得到輸出如下:

$ go run main.go             
GOMAXPROCS = 4

這個結果符合預期,輸出結果等於宿主機上可用的 CPU 核心數。

容器環境

我們再來看下前面的示例程序在 Docker 容器環境下的表現。

為了能夠在 Docker 容器中執行示例程序,首先我們將程序進行交叉編譯,得到 Linux 平台可執行的二進制文件 gomaxprocs

$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o gomaxprocs main.go

然後執行如下命令,在 Docker 容器中運行示例 gomaxprocs

$ docker run --cpus=2 -it \
 -v $(pwd):/app -w /app alpine \
./gomaxprocs               
GOMAXPROCS = 4

在執行容器時,我傳遞了 --cpus=2 參數來限制容器內能夠使用的 CPU 數量最大為 2。可以看到,GOMAXPROCS 輸出結果依然為 4。這説明 Go 程序並沒有清楚的識別到自己在容器中運行,其 P 的數量依然設置為宿主機的可用 CPU 核心數。這樣看來容器並不能限制 Go 程序能夠識別的 CPU 核心數,那麼這個容器的隔離環境效果也就大大折扣。儘管 Go 程序為 P 設置了一個較大的數值,但容器執行時只會給容器內進程 2 核的 CPU 使用,而過多的 P 數量可能導致頻繁的上下文切換,這將會嚴重影響 Go 程序的性能。

NOTE:

使用如下命令可以查看 docker run 命令如何限制 CPU。

$ docker run --help | grep cpu
      --cpu-period int                   Limit CPU CFS (Completely Fair Scheduler) period
      --cpu-quota int                    Limit CPU CFS (Completely Fair Scheduler) quota
      --cpu-rt-period int                Limit CPU real-time period in microseconds
      --cpu-rt-runtime int               Limit CPU real-time runtime in microseconds
  -c, --cpu-shares int                   CPU shares (relative weight)
      --cpus decimal                     Number of CPUs
      --cpuset-cpus string               CPUs in which to allow execution (0-3, 0,1)
      --cpuset-mems string               MEMs in which to allow execution (0-3, 0,1)

那麼如何解決這個問題呢?現在是時候讓 Uber automaxprocs 登場了。

使用 Uber automaxprocs

Uber 公司是著名的 Go 應用廠商,其內部大量使用 Go 語言來開發程序,並且開源了很多 Go 包,比如流行的 Go 日誌處理包 zap。同時 Uber 也開源了 automaxprocs 包用於解決 Go 程序在容器內無法正確識別運行環境中 CPU 核心數的問題。

接下來,我們就使用 automaxprocs 來看一下效果。

首先我們通過如下命令來安裝 automaxprocs

$ go get -u go.uber.org/automaxprocs

之後就可以使用 automaxprocs 了。

使用方式非常簡單,只需要以匿名的方式導入 automaxprocs 包即可。

automaxprocs/main.go
package main

import (
    "fmt"
    "runtime"

    _ "go.uber.org/automaxprocs"
)

func main() {
    fmt.Printf("GOMAXPROCS = %d\n", runtime.GOMAXPROCS(0))
}

我們先在宿主機環境中執行以上示例代碼,看一下效果。

$ go run automaxprocs/main.go
2025/04/10 23:37:57 maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined
GOMAXPROCS = 4

可以發現輸出的 GOMAXPROCS 值依然為 4,這沒什麼問題。

此外,這裏還會打印一行日誌,其中 CPU quota undefined 表示未檢測到容器 CPU 配額,説明 automaxprocs 識別到 Go 程序未運行在容器環境。

現在,我們再來看下容器環境中 automaxprocs 的效果。同樣使用交叉編譯,得到 Linux 平台可執行的二進制文件 automaxprocs

$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o automaxprocs/automaxprocs automaxprocs/main.go

然後執行如下命令,在 Docker 容器中運行示例 automaxprocs

$ docker run --cpus=2 -it \                                                                       
 -v $(pwd):/app -w /app alpine \
./automaxprocs/automaxprocs
2025/04/10 15:41:01 maxprocs: Updating GOMAXPROCS=2: determined from CPU quota
GOMAXPROCS = 2

可以發現,這一次輸出的 GOMAXPROCS 值為 2,等於我們設置的參數 --cpus=2,説明 automaxprocs 包生效了。

Kubernetes 環境

因為在企業中,大多數情況都是以 Kubernetes 的方式來部署 Go 應用,所以我們有必要在 Kubernetes 環境中來驗證一下 automaxprocs 包的效果。

現在我們的示例程序目錄如下:

$ tree -F .
./
├── Dockerfile
├── automaxprocs/
│   ├── automaxprocs* # Linux 平台下可執行的二進制文件
│   └── main.go
├── deploy/
│   └── pod.yaml
├── go.mod
├── go.sum
├── gomaxprocs* # Linux 平台下可執行的二進制文件
└── main.go

3 directories, 8 files

我們將使用 Dockerfile 打包一個鏡像,然後在 Kubernetes 環境中以 Pod 的方式來運行 Go 程序。

Dockerfile 內容如下:

Dockerfile
FROM alpine:latest
LABEL authors="jianghushinian"

WORKDIR /app

COPY . .

Kubernetes 的 Pod Yaml 文件內容如下:

deploy/pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: test-automaxprocs
  namespace: default
spec:
  containers:
    - name: without-automaxprocs
      image: automaxprocs:latest
      imagePullPolicy: IfNotPresent
      command: [ "/app/gomaxprocs" ]
      resources:
        limits:
          cpu: "1" # 對應 docker run 中 --cpus=1
    - name: with-automaxprocs
      image: automaxprocs:latest
      imagePullPolicy: IfNotPresent
      command: [ "/app/automaxprocs/automaxprocs" ]
      resources:
        limits:
          cpu: "1"
  restartPolicy: Never

這個 Pod 包含兩個容器,without-automaxprocs 運行沒有使用 automaxprocs 包的 Go 程序,with-automaxprocs 運行使用了 automaxprocs 包的 Go 程序。並且這兩個容器都限制了可用的 CPU 核心數為 1。

在 Kubernetes 集羣中運行 Pod,輸出結果如下:

# 啓動 Pod
$ kubectl apply -f deploy/pod.yaml 
pod/test-automaxprocs created

# 確認 Pod 執行完成
$ kubectl get pod                 
NAME                READY   STATUS      RESTARTS   AGE
test-automaxprocs   0/2     Completed   0          3s

# 查看 without-automaxprocs 容器執行後的輸出結果
$ kubectl logs test-automaxprocs -c without-automaxprocs
GOMAXPROCS = 4

# 查看 with-automaxprocs 容器執行後的輸出結果
$ kubectl logs test-automaxprocs -c with-automaxprocs 
2025/04/10 16:43:51 maxprocs: Updating GOMAXPROCS=1: determined from CPU quota
GOMAXPROCS = 1

以上結果説明 automaxprocs 包在 Kubernetes 運行的容器環境中也依然生效。

那麼,你是否好奇 automaxprocs 包是如何實現的呢?接下來,我將帶你深入到 automaxprocs 包源碼,來探究其實現原理。

源碼解讀

既然我們在使用 automaxprocs 包的方式是通過匿名導入,那麼很自然的就應該想到,背後是 automaxprocs 包的 init 函數在起作用。

automaxprocs 包的 init 函數實現如下:

https://github.com/uber-go/automaxprocs/blob/master/automaxprocs.go#L31
func init() {
    maxprocs.Set(maxprocs.Logger(log.Printf))
}

看來 automaxprocs 包是通過 maxprocs.Set 函數來識別並設置正確的 P 數量的。其參數 maxprocs.Logger(log.Printf) 顯然是用來設置日誌輸出的,這也是為什麼我們匿名導入 automaxprocs 包後,每次執行程序,都會有日誌輸出打印 GOMAXPROCS 的值的原因。

maxprocs.Set 函數實現如下:

https://github.com/uber-go/automaxprocs/blob/master/maxprocs/maxprocs.go#L91
// 自動設置 GOMAXPROCS 以匹配容器 CPU 配額,返回撤銷函數和錯誤
// 僅在 Linux 系統下且有 CPU 配額時生效,其他情況無操作(no-op)
func Set(opts ...Option) (func(), error) {
    // 配置初始化
    cfg := &config{
        procs:          iruntime.CPUQuotaToGOMAXPROCS, // 根據 CPU 配額計算 P 的函數
        roundQuotaFunc: iruntime.DefaultRoundFunc,     // 配額舍入策略,默認向下取整(math.Floor)
        minGOMAXPROCS:  1,                             // P 最小值為 1
    }
    // 應用自定義選項
    for _, o := range opts {
        o.apply(cfg)
    }

    // 初始化默認撤銷函數
    undoNoop := func() {
        cfg.log("maxprocs: No GOMAXPROCS change to reset")
    }

    // 如果存在 GOMAXPROCS 環境變量,則説明用户想要手動設置 P 的值,所以這裏直接返回,跳過自動設置
    if max, exists := os.LookupEnv(_maxProcsKey); exists {
        cfg.log("maxprocs: Honoring GOMAXPROCS=%q as set in environment", max)
        return undoNoop, nil
    }

    // 根據 CPU 配額計算 P
    // 參數 cfg.minGOMAXPROCS 表示確保返回值大於此值
    // 參數 cfg.roundQuotaFunc 用來設置配額舍入策略
    maxProcs, status, err := cfg.procs(cfg.minGOMAXPROCS, cfg.roundQuotaFunc)
    if err != nil {
        return undoNoop, err
    }

    // 配額未定義時直接返回,跳過自動設置
    // 比如非容器環境下
    if status == iruntime.CPUQuotaUndefined {
        cfg.log("maxprocs: Leaving GOMAXPROCS=%v: CPU quota undefined", currentMaxProcs())
        return undoNoop, nil
    }

    // 獲取當前 P 的值,構造撤銷函數
    // 調用方可以調用 undo() 來撤銷對 P 值的修改
    prev := currentMaxProcs()
    undo := func() {
        cfg.log("maxprocs: Resetting GOMAXPROCS to %v", prev)
        runtime.GOMAXPROCS(prev)
    }

    // 分類處理日誌記錄
    switch status {
    case iruntime.CPUQuotaMinUsed: // CPU 配額值小於配置的最低值,使用 minGOMAXPROCS
        cfg.log("maxprocs: Updating GOMAXPROCS=%v: using minimum allowed GOMAXPROCS", maxProcs)
    case iruntime.CPUQuotaUsed: // 使用正常計算值
        cfg.log("maxprocs: Updating GOMAXPROCS=%v: determined from CPU quota", maxProcs)
    }

    // 設置計算後的 P 值,並返回撤銷函數 undo
    runtime.GOMAXPROCS(maxProcs)
    return undo, nil
}

maxprocs.Set 函數採用了選項模式,可以自定義一些配置項。默認情況下使用 iruntime.CPUQuotaToGOMAXPROCS 函數來根據 CPU 配額計算 P 值。如果用户指定了 GOMAXPROCS 環境變量,則跳過自動設置 P 值,否則,使用 iruntime.CPUQuotaToGOMAXPROCS 函數進行計算並設置 P 值。最終返回 undo 函數,可以用來撤銷對 P 值的修改。

其中 currentMaxProcs 函數實現如下:

func currentMaxProcs() int {
    return runtime.GOMAXPROCS(0)
}

iruntime.DefaultRoundFunc 函數實現如下:

func DefaultRoundFunc(v float64) int {
    return int(math.Floor(v))
}

接下來我們重點關注下 iruntime.CPUQuotaToGOMAXPROCS 函數的實現。

maxprocs.Set 函數的註釋中我有提到,Set 函數僅在 Linux 系統下且有 CPU 配額時生效,其他情況無操作(no-op)。所以 CPUQuotaToGOMAXPROCS 函數在不同的平台必然有不同的實現。

以下是非 Linux 系統中的實現:

https://github.com/uber-go/automaxprocs/blob/master/internal/runtime/cpu_quota_unsupported.go
//go:build !linux
// +build !linux

package runtime

// CPUQuotaToGOMAXPROCS converts the CPU quota applied to the calling process
// to a valid GOMAXPROCS value. This is Linux-specific and not supported in the
// current OS.
func CPUQuotaToGOMAXPROCS(_ int, _ func(v float64) int) (int, CPUQuotaStatus, error) {
    return -1, CPUQuotaUndefined, nil
}

比如我的 Mac 系統中,就會執行這個實現。

而以下是 Linux 系統中的實現:

https://github.com/uber-go/automaxprocs/blob/master/internal/runtime/cpu_quota_linux.go#L35
func CPUQuotaToGOMAXPROCS(minValue int, round func(v float64) int) (int, CPUQuotaStatus, error) {
    // 如果傳進來的參數 round 為 nil,則設置為默認的向下取整策略
    if round == nil {
        round = DefaultRoundFunc
    }

    // 檢測 cgroups 版本
    cgroups, err := _newQueryer()
    if err != nil {
        return -1, CPUQuotaUndefined, err
    }

    // 根據 cgroups 獲取 CPU 配額
    quota, defined, err := cgroups.CPUQuota()
    if !defined || err != nil {
        return -1, CPUQuotaUndefined, err
    }

    // 計算並校驗配額
    maxProcs := round(quota)
    if minValue > 0 && maxProcs < minValue {
        return minValue, CPUQuotaMinUsed, nil // 使用最低保障值
    }
    // 返回計算結果
    return maxProcs, CPUQuotaUsed, nil
}

Linux 系統中的 CPUQuotaToGOMAXPROCS 函數實現代碼邏輯不多,主要邏輯都封裝在 _newQueryer() 返回的對象中。

_newQueryer() 實現如下:

https://github.com/uber-go/automaxprocs/blob/master/internal/runtime/cpu_quota_linux.go#L66
type queryer interface {
    CPUQuota() (float64, bool, error)
}

var (
    _newCgroups2 = cg.NewCGroups2ForCurrentProcess
    _newCgroups  = cg.NewCGroupsForCurrentProcess
    _newQueryer  = newQueryer
)

func newQueryer() (queryer, error) {
    // 這裏先直接判斷是否為 cgroups v2,如果是,直接返回,否則繼續判斷是否為 cgroups v1
    cgroups, err := _newCgroups2()
    if err == nil {
        return cgroups, nil
    }
    // 如果不是 cgroups v2,則判斷是否為 cgroups v1
    if errors.Is(err, cg.ErrNotV2) {
        return _newCgroups()
    }
    return nil, err
}

可以發現,_newQueryer() 函數等於 newQueryer 函數,而 newQueryer 函數返回一個 queryer 接口,這個接口僅有一個方法 CPUQuota 用來計算 CPU 配額。

queryer 接口的實現有兩個,分別是 cg.NewCGroupsForCurrentProcesscg.NewCGroups2ForCurrentProcess。而這二者也正對應的 cgroups v1/v2 兩個版本。

所以,代碼閲讀到這裏,其實我們已經幾乎探究到了 automaxprocs 包的實現原理,就是通過檢查當前 Go 程序所在環境的 cgroups v1/v2 版本,來判斷是當前環境是否在容器中,並以此來獲取並設置正確的 P 值。這也是為什麼 automaxprocs 包只能支持 Linux 系統的緣故,因為只有 Linux 實現了 cgroups,以此來實現容器功能。

那麼接下來其實我們要看的就是 cg.NewCGroupsForCurrentProcesscg.NewCGroups2ForCurrentProcess 兩個函數的具體實現了。

不過,這兩個函數代碼實現比較多,我就不貼出來了,我把這兩個函數各自相關的常量貼出來,你就能大致明白其實現原理了。

cg.NewCGroupsForCurrentProcess 函數涉及的部分常量如下:

https://github.com/uber-go/automaxprocs/blob/master/internal/cgroups/cgroups.go
const (
    // _cgroupFSType is the Linux CGroup file system type used in
    // `/proc/$PID/mountinfo`.
    _cgroupFSType = "cgroup"
    // _cgroupSubsysCPU is the CPU CGroup subsystem.
    _cgroupSubsysCPU = "cpu"
    // _cgroupSubsysCPUAcct is the CPU accounting CGroup subsystem.
    _cgroupSubsysCPUAcct = "cpuacct"
    // _cgroupSubsysCPUSet is the CPUSet CGroup subsystem.
    _cgroupSubsysCPUSet = "cpuset"
    // _cgroupSubsysMemory is the Memory CGroup subsystem.
    _cgroupSubsysMemory = "memory"

    // _cgroupCPUCFSQuotaUsParam is the file name for the CGroup CFS quota
    // parameter.
    _cgroupCPUCFSQuotaUsParam = "cpu.cfs_quota_us"
    // _cgroupCPUCFSPeriodUsParam is the file name for the CGroup CFS period
    // parameter.
    _cgroupCPUCFSPeriodUsParam = "cpu.cfs_period_us"
)

const (
    _procPathCGroup    = "/proc/self/cgroup"
    _procPathMountInfo = "/proc/self/mountinfo"
)

在這裏,我們可以看到 cpu.cfs_quota_uscpu.cfs_period_us 兩個關鍵的值。這兩個值其實是 Linux 中的兩個文件,是 cgroups v1 用來限制 CPU 使用量的,而它們分別代表着 CPU 時間配額(每週期內可使用的 CPU 時間上限)和 CPU 時間週期。

我們可以在使用了 cgroups v1 的機器中用如下命令在容器中來看一下這兩個文件中的實際內容:

$ docker run --cpus=2 -it --rm alpine sh
/ # cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
200000
/ # cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
100000

CPU 時間配額和時間週期的值分別為 200000100000,單位是 us,它的含義是:在每 100 ms 的時間裏,此容器中的進程能夠使用 200 ms 的 CPU 時間。即 CPU 的核心數為 200000/100000 = 2 核。

cg.NewCGroups2ForCurrentProcess 函數涉及的部分常量如下:

https://github.com/uber-go/automaxprocs/blob/master/internal/cgroups/cgroups2.go
const (
    // _cgroupv2CPUMax is the file name for the CGroup-V2 CPU max and period
    // parameter.
    _cgroupv2CPUMax = "cpu.max"
    // _cgroupFSType is the Linux CGroup-V2 file system type used in
    // `/proc/$PID/mountinfo`.
    _cgroupv2FSType = "cgroup2"

    _cgroupv2MountPoint = "/sys/fs/cgroup"

    _cgroupV2CPUMaxDefaultPeriod = 100000
    _cgroupV2CPUMaxQuotaMax      = "max"
)

這裏的關鍵值是 cpu.max/sys/fs/cgroup。這是 cgroups v2 用來限制 CPU 使用量的文件。

我們可以在使用了 cgroups v2 的機器中用如下命令在容器中來看一下這個文件中的實際內容:

$ docker run --cpus=2 -it --rm alpine \
  sh -c "cat /sys/fs/cgroup/cpu.max"
200000 100000

不難發現,其實 cgroups v2 就是把 cgroups v1 中 cpu.cfs_quota_uscpu.cfs_period_us 兩個文件的內容,放到了一個文件 cpu.max 中,而內容和效果完全一致。

以上,便是 automaxprocs 包的實現原理。

未來展望

其實,之所以今天想寫這個話題,是因為最近 Go 的一個新提案 issues/73193 中提到了 Go 要在未來版本中加入自動正確設置容器內 Go 程序 P 值的邏輯。即 Go 官方親自下場,來解決這個歷史遺留問題。這個問題由來已久,早在 2019 年的 issues/33803 中就有人提出,如果 Go 官方要來解決此問題,對我們廣大的 Gopher 來説也算是一件好事。

這裏我簡單總結一下 issues/73193 提案,感興趣的讀者也可以點擊跳轉過去查閲原文。

此提案旨在讓 Go 運行時自動適配容器環境,根據 CPU 配額、CPU 親和性和物理機核心數來綜合考量,智能的設置 GOMAXPROCS。其優化規則如下:

  1. 獲取 3 個關鍵參數

    1. 機器的可用邏輯 CPU 核心數
    2. 通過系統調用 sched_getaffinity() 得到的可用 CPU 核心數(CPU 親和性限制)
    3. 如果進程在容器中運行,計算 cgroups v1/v2 中的 CPU 核心數
  2. 計算 GOMAXPROCS 值,取步驟 1 中最小值

當然,現在還處於提案階段,後續在新版本的 Go 程序中到底如何實現還需要查看具體代碼。先挖個坑,到時候我會再寫一篇文章來分析,歡迎先關注我 https://jianghushinian.cn/about/。

總結

本文講解了如何使用 Uber automaxprocs 包正確設置 GOMAXPROCS

默認情況下 Go 程序無法識別自己是在容器中運行,導致其自動創建的 P 數量等於宿主機中的可用邏輯 CPU 核心數,從而可能引發性能問題。

automaxprocs 包就是專門用來解決此問題的,並且用法非常簡單,只需要使用匿名導入的方式 import _ "go.uber.org/automaxprocs" 一行代碼即可搞定。

我還帶你一起閲讀了 automaxprocs 包的核心部分源碼,來深入探究其實現原理。通過閲讀源碼,我們知道 automaxprocs 包其實是讀取了 cgroups v/v2 中用來配置 CPU 配額的文件,來感知容器內可用 CPU 核數的,以此來實現在容器內自動為 Go 程序設置正確的 GOMAXPROCS 值。

並且,我還提到了 Go 官方下場,將在未來的 Go 版本中解決 GOMAXPROCS 不正確的問題,敬請期待。

最後,留一個小作業,如果你覺得 automaxprocs 包打印的日誌不符合你的項目規範,如何自定義日誌呢?這個問題就交給你自行去探索了。

2025/04/10 23:37:57 maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined

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

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

延伸閲讀

  • automaxprocs GitHub 源碼地址:https://github.com/uber-go/automaxprocs
  • Go GOMAXPROCS 提案 issues/73193:https://github.com/golang/go/issues/73193
  • Go issues/33803:https://github.com/golang/go/issues/33803
  • 本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/automaxprocs
  • 本文永久地址:https://jianghushinian.cn/2025/04/13/automaxprocs/

聯繫我

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

Add a new 评论

Some HTML is okay.