docker容器环境go程序的GOMAXPROCS问题

1、前言

随着docker和kubernetes的普及,越来越多的服务选择了容器化部署,这当然包含了一部分go语言程序,而go程序的GOMAXPROCS在容器环境需要特别关注。

2、GOMAXPROCS

2.1 GMP模型

go使用GMP调度模型,其中G对应goroutine,M对应操作系统线程(OS Thread),P是Processor的缩写,代表一个虚拟的处理器。每个P维护一个本地G队列,go调度器也维护了一个全局的G队列,可以通过CAS的方式无锁访问,工作线程M优先使用自己的局部运行队列中的G,只有必要时才会去访问全局G队列,每个G要想真正运行起来,首先需要被分配一个P。P的数量可以通过GOMAXPROCS函数设置

在这里插入图片描述

2.2 如何设置和获取P的数量?

可以借助runtime包下的GOMAXPROCS函数设置和读取P的数量,以go@v1.17.1为例,GOMAXPROCS函数源码如下:

// go/src/runtime/debug.go
func GOMAXPROCS(n int) int {
    if GOARCH == "wasm" && n > 1 {
        n = 1 // WebAssembly has no threads yet, so only one CPU is possible.
    }

    lock(&sched.lock)
    ret := int(gomaxprocs)
    unlock(&sched.lock)
    if n <= 0 || n == ret {
        return ret
    }

    stopTheWorldGC("GOMAXPROCS")

    // newprocs will be processed by startTheWorld
    newprocs = int32(n)

    startTheWorldGC()
    return ret
}

通过上面代码可以看出,一般情况下,GOMAXPROCS函数入参小于1时,不会修改P的数量,因此可用入参0来获取P的数量;如果想设置P的数量,则传入一个大于或等于1的数即可。

package main

import (
    "fmt"
    "runtime"
)

func main() {
    // 获取当前P的数量
    fmt.Println(runtime.GOMAXPROCS(0))

    // 设置P的数量
    runtime.GOMAXPROCS(4)
    
    // ...
}

2.3 默认P的数量

从v1.5版本起,go程序启动的时候会默认把P的数量设置为CPU数量,例如一个go程序跑在2C4G的虚拟机或者物理机上,则P的数量默认为2。

我们以v1.17.1为例,从go源码上稍微追踪下这段逻辑。在go/src/runtime/proc.go的schedinit调度器初始化函数前有如下注释:

// go/src/runtime/proc.go
// The bootstrap sequence is:
//
//    call osinit
//    call schedinit
//    make & queue new G
//    call runtime·mstart
//
// The new G calls runtime·main.

说明了在main函数前bootstrap会做osinit、schedinit等流程,P的数量也是在这些流程中初始化的。

2.3.1 osinit

假设操作系统是linux,osinit相关流程如下:

// go/src/runtime/runtime2.go
var (
    gomaxprocs int32
    ncpu       int32
    /*...*/
    newprocs   int32
)

// go/src/runtime/os_linux.go
func osinit() {
    ncpu = getproccount()
    /*...*/
}

// go/src/runtime/os_linux.go
func getproccount() int32 {
    // This buffer is huge (8 kB) but we are on the system stack
    // and there should be plenty of space (64 kB).
    // Also this is a leaf, so we're not holding up the memory for long.
    // See golang.org/issue/11823.
    // The suggested behavior here is to keep trying with ever-larger
    // buffers, but we don't have a dynamic memory allocator at the
    // moment, so that's a bit tricky and seems like overkill.
    const maxCPUs = 64 * 1024
    var buf [maxCPUs / 8]byte
    // 核心逻辑是sched_getaffinity函数
    r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0])
    if r < 0 {
        return 1
    }
    n := int32(0)
    for _, v := range buf[:r] {
        for v != 0 {
            n += int32(v & 1)
            v >>= 1
        }
    }
    if n == 0 {
        n = 1
    }
    return n
}

//go:noescape
// noescape的作用就是禁止逃逸,指定文件中的下一个声明必须是不带主题的func(意味着该声明的实现不是用Go编写的),不允许将作为参数传递的任何指针逃逸到堆或函数的返回值上
func sched_getaffinity(pid, len uintptr, buf *byte) int32

sched_getaffinity是【获取操作系统cpu亲和性】的方法 ,该方法的详细信息可在linux上执行man sched_getaffinity查看,一般情况下CPU不会对进程做特殊限制,因此这里也就可以获取操作系统层面的CPU数量

2.3.2 schedinit

再来看看schedinit相关逻辑:

// go/src/runtime/proc.go
// The bootstrap sequence is:
//
//    call osinit
//    call schedinit
//    make & queue new G
//    call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
    /*...*/
    procs := ncpu
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n
    }
    if procresize(procs) != nil {
        throw("unknown runnable goroutine during bootstrap")
    }
    /*...*/
}

// go/src/runtime/proc.go
func procresize(nprocs int32) *p {
    /*...*/
    old := gomaxprocs
    
    /*...*/
    
    // 初始化P的数量
    // initialize new P's
    for i := old; i < nprocs; i++ {
        pp := allp[i]
        if pp == nil {
            pp = new(p)
        }
        pp.init(i)
        atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
    }
    
    /*...*/
    
    // 销毁多余的P
    // release resources from unused P's
    for i := nprocs; i < old; i++ {
        p := allp[i]
        p.destroy()
        // can't free P itself because it can be referenced by an M in syscall
    }
    
    /*...*/
    
    var int32p *int32 = &gomaxprocs // make compiler check that gomaxprocs is an int32
    atomic.Store((*uint32)(unsafe.Pointer(int32p)), uint32(nprocs))
    return runnablePs
}

前面提到osinit是在schedinit前执行,而osinit已经把全局变量ncpu设置为了操作系统cpu数,因此上面schedinit代码中通过procresize函数把ncpu的值作为P的数量,并设置到全局变量gomaxprocs中,从而完成了GOMAXPROCS的初始化。

2.4 再看GOMAXPROCS函数

2.4.1 获取P的数量

在上面GOMAXPROCS函数源码中可以看出,当入参传入0时,返回的实际上是一个全局变量gomaxprocs,这个变量就是schedinit设置的ncpu,也就是osinit过程获取到的操作系统层面的cpu数量值。

2.4.2 设置P的数量

假设有机器的cpu是8,程序中有设置P数量为4的代码:

runtime.GOMAXPROCS(4)

经过osinit和schedinit后,全局变量ncpu和gomaxprocs的值都是8,于是GOMAXPROCS函数会走如下逻辑:

// go/src/runtime/debug.go
// n = 4
func GOMAXPROCS(n int) int {
    /*...*/
    
    // n = 4
    newprocs = int32(n)
    
    startTheWorldGC()
    return ret
}

// go/src/runtime/proc.go
func startTheWorldGC() {
    startTheWorld()
    /*...*/
}

// go/src/runtime/proc.go
func startTheWorld() {
    systemstack(func() { startTheWorldWithSema(false) })
    /*...*/
}

// go/src/runtime/proc.go
func startTheWorldWithSema(emitTraceEvent bool) int64 {
    /*...*/
    
    procs := gomaxprocs
    if newprocs != 0 {
        procs = newprocs
        newprocs = 0
    }
    p1 := procresize(procs)
    
    /*...*/
}

procresize函数在schedinit中也有调用,传入的procs = 4,小于之前的8,所以在procresize函数内部会走这段逻辑,从而达到了设置P数量的目的:

// go/src/runtime/proc.go
func procresize(nprocs int32) *p {
    /*...*/
    // 销毁多余的P
    // release resources from unused P's
    for i := nprocs; i < old; i++ {
        p := allp[i]
        p.destroy()
        // can't free P itself because it can be referenced by an M in syscall
    }
    /*...*/

docker容器环境

在2.3.1 osinit章节中,我们强调了 sched_getaffinity是【获取操作系统cpu亲和性】的方法 ,但是对于docker容器,它是共享宿主机操作系统内核的,因此docker容器下的go程序会初始化P的数量为宿主机cpu数量

但是在kubernetes集群中,通常宿主机的cpu都会比较大,有的甚至有100+c,而go程序业务通常需要很小规格即可,例如2c4g。假设一个go程序pod容器的request配置了2c4g,而部署到了一个120c的宿主机上,go程序默认会起120个P,但是cgroup限制了go程序只有2c4g的资源,这很可能会因P数量过多导致不必要的资源争抢,反而降低性能。

3、docker容器环境中如何正确设置P的数量

通过前面的分析,docker环境go程序P数量设置过大的原因是go初始化时获取的宿主机cpu与cgroup中数值不一致,我们可以用uber的automaxprocs包把GOMAXPROCS设置为cgroup中cpu数:

package main

import _ "go.uber.org/automaxprocs"

func main() {
  /*...*/
}

来看看uber这个包是怎么实现的:

// go.uber.org/automaxprocs/automaxprocs.go
import (
    "log"
    
    "go.uber.org/automaxprocs/maxprocs"
)

func init() {
    maxprocs.Set(maxprocs.Logger(log.Printf))
}

// go.uber.org/automaxprocs/maxprocs/maxprocs.go
func Set(opts ...Option) (func(), error) {
    cfg := &config{
        procs:         iruntime.CPUQuotaToGOMAXPROCS,
        minGOMAXPROCS: 1,
    }
    
    maxProcs, status, err := cfg.procs(cfg.minGOMAXPROCS)
    if err != nil {
        return undoNoop, err
    }
    
    /*...*/
    
    runtime.GOMAXPROCS(maxProcs)
    return undo, nil
}

// go.uber.org/automaxprocs/internal/runtime/cpu_quota_linux.go
func CPUQuotaToGOMAXPROCS(minValue int) (int, CPUQuotaStatus, error) {
    cgroups, err := newQueryer()
    if err != nil {
        return -1, CPUQuotaUndefined, err
    }

    quota, defined, err := cgroups.CPUQuota()
    if !defined || err != nil {
        return -1, CPUQuotaUndefined, err
    }

    maxProcs := int(math.Floor(quota))
    if minValue > 0 && maxProcs < minValue {
        return minValue, CPUQuotaMinUsed, nil
    }
    return maxProcs, CPUQuotaUsed, nil
}

// go.uber.org/automaxprocs/internal/runtime/cpu_quota_linux.go
func newQueryer() (queryer, error) {
    // 先查询cgroup v2
    cgroups, err := _newCgroups2()
    if err == nil {
        return cgroups, nil
    }
    // 再查cgroups v1
    if errors.Is(err, cg.ErrNotV2) {
        return _newCgroups()
    }
    return nil, err
}

// go.uber.org/automaxprocs/internal/cgroup/cgroups2.go
// cgroups v2
func (cg *CGroups2) CPUQuota() (float64, bool, error) {
    cpuMaxParams, err := os.Open(path.Join(cg.mountPoint, cg.groupPath, cg.cpuMaxFile))
    if err != nil {
        if os.IsNotExist(err) {
            return -1, false, nil
        }
        return -1, false, err
    }
    defer cpuMaxParams.Close()

    scanner := bufio.NewScanner(cpuMaxParams)
    if scanner.Scan() {
        fields := strings.Fields(scanner.Text())
        if len(fields) == 0 || len(fields) > 2 {
            return -1, false, fmt.Errorf("invalid format")
        }

        if fields[_cgroupv2CPUMaxQuotaIndex] == _cgroupV2CPUMaxQuotaMax {
            return -1, false, nil
        }

        max, err := strconv.Atoi(fields[_cgroupv2CPUMaxQuotaIndex])
        if err != nil {
            return -1, false, err
        }

        var period int
        if len(fields) == 1 {
            period = _cgroupV2CPUMaxDefaultPeriod
        } else {
            period, err = strconv.Atoi(fields[_cgroupv2CPUMaxPeriodIndex])
            if err != nil {
                return -1, false, err
            }

            if period == 0 {
                return -1, false, errors.New("zero value for period is not allowed")
            }
        }

        return float64(max) / float64(period), true, nil
    }

    if err := scanner.Err(); err != nil {
        return -1, false, err
    }

    return 0, false, io.ErrUnexpectedEOF
}

从代码可以看出,uber的automaxprocs包会去读进程cgroups(先找cgroups v2,如果没找到再找cgroups v1)中cpu相关数据,最后通过runtime.GOMAXPROCS设置P的数量为cgroup中cpu对应值,从而解决docker容器环境go程序默认设置P的数量为宿主机cpu数量的问题。

微信公众号卡巴斯同步发布,欢迎大家关注。

<think>我们正在处理一个关于在Docker部署时出现"fatal error: concurrent map writes"错误的问题。根据引用[2]、[3]和[4],这个错误通常是由于多个goroutine并发地写入同一个map导致的。在Go语言中,map不是并发安全的数据结构,因此当多个goroutine同时写入同一个map时,就会触发这个错误。 解决方案通常包括: 1. 使用`sync.Map`(Go 1.9引入)来代替普通的map,`sync.Map`是并发安全的。 2. 使用互斥锁(如`sync.Mutex`或`sync.RWMutex`)来保护普通的map,确保同一时间只有一个goroutine可以写入。 3. 使用第三方库,如`concurrent-map`,它提供了分片锁的map实现,可以提高并发性能。 根据引用[4],我们可以通过定义一个结构体来包装map,并在结构体中使用读写锁(`sync.RWMutex`)来保护map的读写操作。这样,在写操作时使用写锁,读操作时使用读锁,从而避免并发写入问题。 另外,引用[3]提到,可以使用`-race`参数来检测并发问题,这有助于在开发阶段发现潜在的竞态条件。 现在,针对用户的问题,我们提供以下解决方案步骤: 步骤1:检查代码中并发写入map的地方 步骤2:根据情况选择使用`sync.Map`或使用互斥锁来保护map 示例代码(使用互斥锁): 根据引用[4],我们可以这样实现: ```go type SafeMap struct { sync.RWMutex Map map[string]string } var safeMap *SafeMap func init() { safeMap = &SafeMap{ Map: make(map[string]string), } } // 读操作 func (m *SafeMap) Get(key string) (string, bool) { m.RLock() defer m.RUnlock() value, ok := m.Map[key] return value, ok } // 写操作 func (m *SafeMap) Set(key string, value string) { m.Lock() defer m.Unlock() m.Map[key] = value } ``` 步骤3:在代码中替换原来的map操作,使用这些安全的方法。 步骤4:使用`go run -race`或`go build -race`来检测是否还存在竞态条件。 此外,用户提到是在Docker部署时出现该错误,因此还需要确保在Docker环境中运行的程序与本地测试环境一致,并且没有因为Docker的配置(如CPU限制)导致goroutine调度异常。但通常,这个错误是代码问题,所以重点应放在修复代码上。 如果用户使用的是第三方库,需要检查该库是否支持并发安全,如果不支持,可能需要自己封装或更换库。 最后,如果用户的问题是在运行一个已有的程序(可能是第三方程序),那么可能需要检查该程序的版本,并查看是否有已知的修复该问题的更新版本。 相关问题: 1. 如何检测Go程序中的并发问题? 2. 除了使用互斥锁,还有哪些方法可以实现并发安全的map? 3. 在Docker环境中部署Go程序时,有哪些常见的并发问题需要注意?</think>### Docker部署中解决"fatal error: concurrent map writes"错误 这个错误是由于Go程序中多个goroutine同时写入同一个map导致的并发冲突。在Docker环境中,这种问题可能因容器调度机制放大。以下是系统性的解决方案: #### 解决方案步骤 1. **确认并发写入位置** - 使用Go的竞态检测工具定位问题: ```bash go build -race && docker build . -t myapp docker run --rm myapp ``` - 日志中会显示具体发生冲突的代码文件和行号 2. **修复代码并发问题** ```go // 方法1:使用sync.Map(适合读多写少场景) var m sync.Map m.Store("key", "value") // 并发安全写入 // 方法2:使用互斥锁保护标准map type SafeMap struct { sync.RWMutex items map[string]string } func (sm *SafeMap) Set(key, value string) { sm.Lock() defer sm.Unlock() sm.items[key] = value // 受保护的写入操作 } ``` 3. **优化Docker部署配置** ```dockerfile # 在Dockerfile中启用竞态检测 FROM golang:1.xx WORKDIR /app COPY . . RUN go build -race -o myapp CMD ["./myapp"] ``` 4. **第三方库解决方案** - 使用[concurrent-map](https://github.com/orcaman/concurrent-map)库: ```go import cmap "github.com/orcaman/concurrent-map/v2" m := cmap.New[string]() m.Set("key", "value") // 分片锁保护 ``` #### 验证方法 1. 在本地运行竞态检测: ```bash go run -race main.go ``` 2. 在Docker中测试: ```bash docker run -it --cpus=2 myapp # 模拟并发压力 ``` #### 预防措施 1. 在CI/CD流水线中加入竞态检测阶段 2. 对共享资源操作统一使用`sync.atomic`或通道 3. 限制goroutine数量,避免过度并发: ```go sem := make(chan struct{}, runtime.NumCPU()*2) // 并发控制 ``` > **重要提示**:Docker的CPU限制可能改变goroutine调度行为,建议在容器中设置`GOMAXPROCS`环境变量: > ```dockerfile > ENV GOMAXPROCS=2 # 匹配容器分配的CPU数 > ``` [^1]: 容器环境可能放大并发问题,需检查资源分配 [^2]: `sync.Map`适用于读多写少场景,写多时性能较差 [^3]: 分片锁(map sharding)能减少锁竞争 [^4]: 竞态检测是定位问题的关键工具
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值