MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go Goroutine卡住的预防措施

2021-09-212.9k 阅读

理解 Goroutine 卡住的根本原因

资源竞争导致的死锁

在 Go 语言中,Goroutine 并发执行时,如果对共享资源的访问没有正确同步,就可能导致死锁。死锁是一种严重的情况,会使 Goroutine 卡住,无法继续执行。

例如,考虑以下代码:

package main

import (
    "fmt"
)

func main() {
    var ch1 = make(chan int)
    var ch2 = make(chan int)

    go func() {
        ch1 <- 1
        fmt.Println("Sent 1 to ch1")
        <-ch2
        fmt.Println("Received from ch2")
    }()

    go func() {
        ch2 <- 2
        fmt.Println("Sent 2 to ch2")
        <-ch1
        fmt.Println("Received from ch1")
    }()

    select {}
}

在这个例子中,两个 Goroutine 互相等待对方发送数据到通道。第一个 Goroutine 发送数据到 ch1 后等待从 ch2 接收,而第二个 Goroutine 发送数据到 ch2 后等待从 ch1 接收,这就形成了死锁。

为了预防这种死锁,我们需要仔细设计通道的使用逻辑,确保数据的发送和接收顺序不会导致循环等待。一种改进的方法是合理安排发送和接收操作,例如:

package main

import (
    "fmt"
)

func main() {
    var ch1 = make(chan int)
    var ch2 = make(chan int)

    go func() {
        ch1 <- 1
        fmt.Println("Sent 1 to ch1")
        <-ch2
        fmt.Println("Received from ch2")
    }()

    go func() {
        <-ch1
        fmt.Println("Received from ch1")
        ch2 <- 2
        fmt.Println("Sent 2 to ch2")
    }()

    select {}
}

通过调整第二个 Goroutine 的操作顺序,先从 ch1 接收数据,再向 ch2 发送数据,避免了死锁。

无缓冲通道的错误使用

无缓冲通道在数据发送和接收时要求双方同时准备好,否则发送或接收操作会阻塞。如果使用不当,也会导致 Goroutine 卡住。

例如:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
        fmt.Println("Sent 1 to ch")
    }()
    // 这里没有从 ch 接收数据的操作,导致发送操作一直阻塞
    select {}
}

在上述代码中,Goroutine 尝试向无缓冲通道 ch 发送数据,但由于没有其他 Goroutine 从该通道接收数据,这个发送操作会永远阻塞,导致该 Goroutine 卡住。

要解决这个问题,我们需要确保在发送数据之前,有相应的接收操作准备好。如下修改代码:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
        fmt.Println("Sent 1 to ch")
    }()
    data := <-ch
    fmt.Println("Received data:", data)
}

这样,主 Goroutine 从通道 ch 接收数据,使得发送操作能够顺利完成。

带缓冲通道的溢出与饥饿

带缓冲通道有一定的容量,如果向已满的带缓冲通道发送数据,而没有及时有接收操作,会导致发送操作阻塞。同样,如果一直从空的带缓冲通道接收数据,而没有发送操作,接收操作也会阻塞。

例如,考虑以下代码:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 2)
    go func() {
        for i := 0; i < 3; i++ {
            ch <- i
            fmt.Printf("Sent %d to ch\n", i)
        }
    }()

    time.Sleep(1 * time.Second)
    for i := 0; i < 3; i++ {
        data := <-ch
        fmt.Printf("Received %d from ch\n", data)
    }
}

在这个例子中,带缓冲通道 ch 的容量为 2,Goroutine 尝试向通道发送 3 个数据,前两个数据可以顺利发送,但第三个数据发送时会阻塞,因为通道已满。解决这个问题的方法可以是增加通道容量,或者及时从通道接收数据。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 3)
    go func() {
        for i := 0; i < 3; i++ {
            ch <- i
            fmt.Printf("Sent %d to ch\n", i)
        }
    }()

    time.Sleep(1 * time.Second)
    for i := 0; i < 3; i++ {
        data := <-ch
        fmt.Printf("Received %d from ch\n", data)
    }
}

此外,还可能出现通道饥饿问题。例如,如果有多个 Goroutine 竞争从一个通道接收数据,而某个 Goroutine 持续快速接收数据,其他 Goroutine 可能会长时间无法接收到数据,从而导致饥饿。为了避免这种情况,可以使用公平调度算法或合理分配接收操作。

资源耗尽引发的卡住

文件描述符耗尽

在 Go 程序中,如果频繁打开文件而没有正确关闭,会导致文件描述符耗尽。每个操作系统对进程可打开的文件描述符数量都有限制。

例如:

package main

import (
    "fmt"
)

func main() {
    for {
        file, err := openFile()
        if err != nil {
            fmt.Println("Error opening file:", err)
            break
        }
        // 这里没有关闭文件,导致文件描述符泄漏
    }
}

func openFile() (*os.File, error) {
    return os.Open("test.txt")
}

为了预防文件描述符耗尽,必须确保在使用完文件后及时关闭。修改后的代码如下:

package main

import (
    "fmt"
    "os"
)

func main() {
    for {
        file, err := openFile()
        if err != nil {
            fmt.Println("Error opening file:", err)
            break
        }
        defer file.Close()
        // 文件操作
    }
}

func openFile() (*os.File, error) {
    return os.Open("test.txt")
}

内存耗尽

如果 Goroutine 不断分配内存,而没有及时释放,可能会导致内存耗尽,使程序崩溃或 Goroutine 卡住。

例如,以下代码会不断向切片中添加元素,导致内存不断增长:

package main

import (
    "fmt"
)

func main() {
    var data []int
    for {
        data = append(data, 1)
        fmt.Println("Added element to data")
    }
}

为了避免内存耗尽,需要合理管理内存使用。如果数据不再需要,及时释放内存。例如,可以使用 caplen 来控制切片的内存分配,或者使用对象池来复用对象。

package main

import (
    "fmt"
)

func main() {
    var data []int
    for i := 0; i < 1000; i++ {
        data = append(data, 1)
        if len(data) == cap(data) {
            // 处理内存分配策略,这里简单示例可以重新分配较小的容量
            newData := make([]int, 0, cap(data)/2)
            newData = append(newData, data...)
            data = newData
        }
        fmt.Println("Added element to data")
    }
}

网络资源耗尽

在进行网络编程时,如果创建了大量的网络连接而没有正确关闭,会导致网络资源耗尽。

例如,以下代码不断创建 TCP 连接但不关闭:

package main

import (
    "fmt"
    "net"
)

func main() {
    for {
        conn, err := net.Dial("tcp", "127.0.0.1:8080")
        if err != nil {
            fmt.Println("Error dialing:", err)
            break
        }
        // 这里没有关闭连接
    }
}

为了预防网络资源耗尽,必须在使用完连接后及时关闭。修改后的代码如下:

package main

import (
    "fmt"
    "net"
)

func main() {
    for {
        conn, err := net.Dial("tcp", "127.0.0.1:8080")
        if err != nil {
            fmt.Println("Error dialing:", err)
            break
        }
        defer conn.Close()
        // 网络操作
    }
}

不合理的同步机制导致的卡住

互斥锁的死锁

互斥锁(sync.Mutex)用于保护共享资源,确保同一时间只有一个 Goroutine 可以访问。但如果使用不当,也会导致死锁。

例如:

package main

import (
    "fmt"
    "sync"
)

var mu1 sync.Mutex
var mu2 sync.Mutex

func main() {
    go func() {
        mu1.Lock()
        fmt.Println("Locked mu1 in goroutine 1")
        mu2.Lock()
        fmt.Println("Locked mu2 in goroutine 1")
        mu2.Unlock()
        fmt.Println("Unlocked mu2 in goroutine 1")
        mu1.Unlock()
        fmt.Println("Unlocked mu1 in goroutine 1")
    }()

    go func() {
        mu2.Lock()
        fmt.Println("Locked mu2 in goroutine 2")
        mu1.Lock()
        fmt.Println("Locked mu1 in goroutine 2")
        mu1.Unlock()
        fmt.Println("Unlocked mu1 in goroutine 2")
        mu2.Unlock()
        fmt.Println("Unlocked mu2 in goroutine 2")
    }()

    select {}
}

在这个例子中,两个 Goroutine 以不同的顺序获取互斥锁 mu1mu2,导致死锁。为了避免这种情况,应该始终以相同的顺序获取互斥锁。

package main

import (
    "fmt"
    "sync"
)

var mu1 sync.Mutex
var mu2 sync.Mutex

func main() {
    go func() {
        mu1.Lock()
        fmt.Println("Locked mu1 in goroutine 1")
        mu2.Lock()
        fmt.Println("Locked mu2 in goroutine 1")
        mu2.Unlock()
        fmt.Println("Unlocked mu2 in goroutine 1")
        mu1.Unlock()
        fmt.Println("Unlocked mu1 in goroutine 1")
    }()

    go func() {
        mu1.Lock()
        fmt.Println("Locked mu1 in goroutine 2")
        mu2.Lock()
        fmt.Println("Locked mu2 in goroutine 2")
        mu2.Unlock()
        fmt.Println("Unlocked mu2 in goroutine 2")
        mu1.Unlock()
        fmt.Println("Unlocked mu1 in goroutine 2")
    }()

    select {}
}

读写锁的死锁与饥饿

读写锁(sync.RWMutex)允许在同一时间有多个读操作,但只允许一个写操作。如果使用不当,也会出现问题。

例如,死锁情况:

package main

import (
    "fmt"
    "sync"
)

var rwmu sync.RWMutex

func main() {
    go func() {
        rwmu.Lock()
        fmt.Println("Locked for write in goroutine 1")
        rwmu.RLock()
        fmt.Println("Locked for read in goroutine 1")
        rwmu.RUnlock()
        fmt.Println("Unlocked read in goroutine 1")
        rwmu.Unlock()
        fmt.Println("Unlocked write in goroutine 1")
    }()

    go func() {
        rwmu.RLock()
        fmt.Println("Locked for read in goroutine 2")
        rwmu.Lock()
        fmt.Println("Locked for write in goroutine 2")
        rwmu.Unlock()
        fmt.Println("Unlocked write in goroutine 2")
        rwmu.RUnlock()
        fmt.Println("Unlocked read in goroutine 2")
    }()

    select {}
}

在这个例子中,第一个 Goroutine 先获取写锁,再尝试获取读锁,而第二个 Goroutine 先获取读锁,再尝试获取写锁,导致死锁。为了避免死锁,应该按照合理的顺序获取锁。

同时,读写锁还可能出现饥饿问题。如果读操作频繁,写操作可能会长时间无法获取锁,导致饥饿。为了避免饥饿,可以采用公平调度算法,例如在每次读操作后,给写操作一定的优先级。

package main

import (
    "fmt"
    "sync"
    "time"
)

var rwmu sync.RWMutex
var writePending bool

func read() {
    for {
        for writePending {
            time.Sleep(10 * time.Millisecond)
        }
        rwmu.RLock()
        fmt.Println("Read lock acquired")
        // 模拟读操作
        time.Sleep(50 * time.Millisecond)
        rwmu.RUnlock()
        fmt.Println("Read lock released")
    }
}

func write() {
    for {
        writePending = true
        rwmu.Lock()
        fmt.Println("Write lock acquired")
        // 模拟写操作
        time.Sleep(100 * time.Millisecond)
        rwmu.Unlock()
        fmt.Println("Write lock released")
        writePending = false
    }
}

func main() {
    go read()
    go write()

    select {}
}

在这个改进的代码中,通过 writePending 标志位,在读操作时检查是否有写操作等待,从而给写操作一定的优先级,避免写操作饥饿。

长时间阻塞操作导致的卡住

系统调用的阻塞

在 Go 中,一些系统调用可能会阻塞当前 Goroutine,例如 time.Sleep、网络 I/O 操作等。如果这些阻塞操作没有合理处理,可能会导致整个 Goroutine 卡住。

例如,以下代码中 time.Sleep 会阻塞主 Goroutine:

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("Start")
    time.Sleep(5 * time.Second)
    fmt.Println("End")
}

如果这是在一个 Goroutine 中,并且这个 Goroutine 负责处理其他重要任务,长时间的睡眠可能会影响整个程序的响应。解决方法可以是将阻塞操作放在单独的 Goroutine 中,或者使用更细粒度的时间控制。

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("Start")
    go func() {
        time.Sleep(5 * time.Second)
        fmt.Println("End in goroutine")
    }()
    // 主 Goroutine 可以继续执行其他任务
    for i := 0; i < 3; i++ {
        fmt.Println("Doing other things in main goroutine:", i)
        time.Sleep(1 * time.Second)
    }
    select {}
}

计算密集型操作的阻塞

计算密集型操作也可能阻塞 Goroutine。例如,以下代码进行大量的数学计算:

package main

import (
    "fmt"
)

func heavyCalculation() {
    var result int
    for i := 0; i < 1000000000; i++ {
        result += i
    }
    fmt.Println("Calculation result:", result)
}

func main() {
    fmt.Println("Start")
    heavyCalculation()
    fmt.Println("End")
}

如果这个计算操作在一个 Goroutine 中执行,会阻塞该 Goroutine,影响其他 Goroutine 的执行。为了避免这种情况,可以将计算任务拆分成多个子任务,并行执行。

package main

import (
    "fmt"
    "sync"
)

func subCalculation(start, end int, wg *sync.WaitGroup) {
    var result int
    for i := start; i < end; i++ {
        result += i
    }
    fmt.Printf("Sub - calculation result from %d to %d: %d\n", start, end, result)
    wg.Done()
}

func main() {
    fmt.Println("Start")
    var wg sync.WaitGroup
    numPartitions := 4
    partitionSize := 1000000000 / numPartitions
    for i := 0; i < numPartitions; i++ {
        start := i * partitionSize
        end := (i + 1) * partitionSize
        if i == numPartitions - 1 {
            end = 1000000000
        }
        wg.Add(1)
        go subCalculation(start, end, &wg)
    }
    wg.Wait()
    fmt.Println("End")
}

通过将计算任务分成多个子任务,在多个 Goroutine 中并行执行,减少了单个 Goroutine 的阻塞时间,提高了程序的整体性能。

监测与调试 Goroutine 卡住问题

使用 runtime/debug

runtime/debug 包提供了一些函数来帮助调试 Goroutine 相关问题。例如,Stack 函数可以获取当前 Goroutine 的堆栈信息。

package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered from panic:", r)
                fmt.Println(string(debug.Stack()))
            }
        }()
        // 模拟可能导致 panic 的操作
        var data []int
        fmt.Println(data[10])
    }()

    select {}
}

在这个例子中,通过 defer 语句在 Goroutine 发生 panic 时获取堆栈信息,有助于定位问题。

使用 pprof 进行性能分析

pprof 工具可以帮助分析程序的性能,包括 Goroutine 的执行情况。

首先,在代码中引入 net/httpruntime/pprof 包:

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        fmt.Println("Starting pprof server")
        http.ListenAndServe("localhost:6060", nil)
    }()

    // 主程序逻辑
    select {}
}

然后,通过浏览器访问 http://localhost:6060/debug/pprof/goroutine,可以查看当前所有 Goroutine 的状态和堆栈信息,从而发现可能卡住的 Goroutine 并进行分析。

自定义监测机制

除了使用内置的工具,还可以自定义监测机制。例如,通过在关键代码位置记录时间戳,来监测 Goroutine 的执行时间。

package main

import (
    "fmt"
    "time"
)

func monitoredFunction() {
    start := time.Now()
    // 模拟一些操作
    time.Sleep(2 * time.Second)
    elapsed := time.Since(start)
    if elapsed > 1*time.Second {
        fmt.Println("Function took too long:", elapsed)
    }
}

func main() {
    go monitoredFunction()
    select {}
}

通过这种方式,可以在程序运行过程中实时监测 Goroutine 的执行时间,及时发现长时间执行的操作,预防 Goroutine 卡住。

通过对以上各个方面的深入理解和合理运用预防措施,可以有效避免 Go Goroutine 卡住的问题,提高程序的稳定性和性能。在实际开发中,需要根据具体的业务场景和代码逻辑,综合运用这些方法,确保 Goroutine 的正确并发执行。