本文主要是记录一次错误的golang信号量使用方式,导致了cpu单核被占满的问题,希望大家引以为戒。

示例

func test() {
    for {
        c := make(chan os.Signal)
        signal.Notify(c)
        s := <-c
        if s != syscall.SIGKILL {
            continue
        }
        framework.GetLogger().ILog("recv signal: %s", s)
        break
    }
    framework.GetLogger().ILog("service stop")
}

如上代码,在程序中监听了所有信号量,当监听到的不是SIGKILL信号时,就continue继续开启一次新的监听。乍一看似乎没什么问题,并且这段代码一开始部署的时候也是正常跑着的,但是在跑过10多天之后神奇的事情就发生了,该程序cpu吃满了cpu单核。导致cpu单核占满的原因,我们很容易联想到代码中有死循环。

原因分析

在开启pprof监控之后,成功抓到了cpu耗用点,如下图:

可以看到是在信号量loop中占用了特别多的cpu,来看看这里的源码:

func process(sig os.Signal) {
    n := signum(sig)
    if n < 0 {
        return
    }

    handlers.Lock()
    defer handlers.Unlock()

    for c, h := range handlers.m {
        if h.want(n) {
            // send but do not block for it
            select {
            case c <- sig:
            default:
            }
        }
    }

    // Avoid the race mentioned in Stop.
    for _, d := range handlers.stopping {
        if d.h.want(n) {
            select {
            case d.c <- sig:
            default:
            }
        }
    }
}

源码中将信号量分发到handlers.m chan中是不阻塞的,说明有非常多的用于接收信号量的notify被注册进来了,再结合代码逻辑,发现每收到一次信号量,就新注册一个chan进来。这里就有疑问了:为什么在进程正常运行的过程中,操作系统会发送如此多的信号量进来,以至于注册的队列越来越多,导致信号量分发的for循环都空转吃满了cpu呢?

SIGURG (urgent I/O condition)

runtime/proc.go
const forcePreemptNS = 10 * 1000 * 1000

func retake(now int64) uint32 {
    if s == _Prunning || s == _Psyscall {
        // Preempt G if it's running for too long.
        t := int64(_p_.schedtick)
        if int64(pd.schedtick) != t {
            pd.schedtick = uint32(t)
            pd.schedwhen = now
        } else if pd.schedwhen+forcePreemptNS <= now {
            preemptone(_p_)
            // In case of syscall, preemptone() doesn't
            // work, because there is no M wired to P.
            sysretake = true
        }
    }
}

当我们进程在运行过程中,其实偶尔也会有概率出现协程占用超10ms的情况,随着调度抢占的信号收到的次数变多,信号量分发的的压力就会越来越大,而信号量分发本身也在自己的协程中进行,这样就会越来越容易导致抢占信号的触发,从而陷入抢占协程的雪崩中,导致单核cpu被吃满。

正确姿势

func test() {
    c := make(chan os.Signal)
    signal.Notify(c, syscall.SIGKILL)
    for {
        s := <-c
        if s != syscall.SIGKILL {
            framework.GetLogger().WLog("recv signal: %s", s)
            continue
        }
        framework.GetLogger().ILog("recv signal: %s", s)
        break
    }
    framework.GetLogger().ILog("service stop")
}

正确的做法是:我们应该只关心我们需要的信号量,并且只创建一次监听队列。