会诱发 Goroutine 挂起的 27 个原因

1. 理解 Goroutine

Goroutine 是 Go 语言中一个轻量级的线程实现,可以在一个或多个操作系统线程上被多路复用。一个 Goroutine 接近于一个线程,但是它并不需要两倍于一个普通线程的栈空间,一般来说一个 Goroutine 的栈空间只有几 KB,可以同时存在成百上千个 Goroutine。

Goroutine 通过同步通信来实现对共享资源的访问,而不是通过互斥锁来进行同步访问。同步通信的实现需要依靠 channel,后文会详细介绍。这种多路复用的设计方式让 Goroutine 能够以非常低的代价创建和销毁,使得 Go 语言支持海量的并发。

2. Goroutine 的优点

相比于传统的线程实现方式,Goroutine 的优点如下:

2.1 高并发处理

Go 语言的并发模型使得 Goroutine 能够支持高并发处理,同时又不会导致线程创建和销毁的代价变得很高。这是 Go 语言在网络编程中快速发展的原因之一。

2.2 节约资源

Goroutine 的设计使其能够以比较低的代价创建和销毁,一般来说一个 Goroutine 只需要几 KB 的栈空间,多个 Goroutine 可以共享一个操作系统线程的堆栈。因此,Goroutine 能够更加节约资源。

2.3 更好的抽象

Go 语言中的 Goroutine 和 channel 提供了更高层次的抽象,使得编程变得更加简洁。程序员不需要自己去写锁和互斥体来控制并发,而是直接使用 Goroutine 和 channel 同步通信。

3. 会导致 Goroutine 挂起的原因

虽然 Goroutine 的设计让并发编程变得更加简洁和方便,但是编写高效可靠的并发程序仍然是一门艺术。本部分将介绍直接或间接导致 Goroutine 挂起的原因,具体如下:

3.1 调度器挂起

当某个 Goroutine 的执行时间过长或者其在执行期间没有进行 I/O 操作,调度器会考虑挂起当前 Goroutine,转而运行其他 Goroutine 以更好地利用系统的资源。调度器挂起 Goroutine 的行为被称为抢占式调度

调度器挂起 Goroutine 的情况非常普遍,而我们需要确保 Goroutine 在被挂起前,已经执行完必要的操作并暂停,而不是正在执行某些关键的代码。下面是一个可能导致 Goroutine 被挂起的例子。

func main() {

ch := make(chan int)

go func() {

time.Sleep(time.Second)

ch <- 1

}()

time.Sleep(time.Minute)

<-ch

}

上述代码中,我们创建了一个 channel ch,然后创建了一个 Goroutine,该 Goroutine 会在 1 秒后往 ch 中发送一个值。但 main 函数没有等待 Goroutine 执行完,而是等待了 1 分钟,然后从 ch 中读取数据。这会导致 ch 的接收操作阻塞,同时 Goroutine 还没有执行完毕,则被调度器挂起了。

3.2 通道阻塞

由于通道是同步通信的,当一个 Goroutine 向通道发送信息时,如果没有其他 Goroutine 接收,则发送操作会一直阻塞下去。或者,如果一个 Goroutine 尝试从通道接收值,而该通道中没有可用的值,则接收操作将被阻塞。

下面是一个通道阻塞的例子:

func main() {

ch := make(chan int)

go func() {

ch <- 1

}()

<-ch

}

在上述代码中,我们创建了一个空的通道 ch,然后在 Goroutine 中尝试往 ch 中发送一个值。但主函数等待使用 <-ch 接收操作,然而此时 ch 中还没有可用的值,因此主函数会一直阻塞下去,直到 Goroutine 往 ch 中发送一个值。

3.3 资源争用

当多个 Goroutine 尝试同时访问同一份资源(如共享的内存区或文件),可能会产生资源争用问题。

var counter int

func main() {

wg := sync.WaitGroup{}

for i:=0; i<10; i++ {

wg.Add(1)

go func() {

counter++

wg.Done()

}()

}

wg.Wait()

fmt.Println("Counter:", counter)

}

上述代码中,我们创建了一个 counter 变量,然后创建了 10 个 Goroutine,每个 Goroutine 对 counter 进行加一的操作。然而由于 Goroutine 的并发性,某些 Goroutine 可能会试图同时写入到 counter 变量中,导致计数器产生错误。

4. Goroutine 挂起的解决方式

Goroutine 挂起的解决方法取决于问题的类型。下面是一些常见的解决方案。

4.1 调度器挂起

处理调度器挂起问题的最好方法是使用 Goroutine 的抢占式调度。在运行时,Go 调度器会定期检查运行的 Goroutine 的运行时间,以及等待 I/O 操作的 Goroutine,然后决定是否将控制权移交给其他 Goroutine。因此,在程度上,每个 Goroutine 对资源的占用时间都应该尽量短,这样才能让其他 Goroutine 有机会执行。下面是一个典型的例子:

func compute(id int, ch chan int) {

for i := 0; i < 5; i++ {

fmt.Printf("Worker %d received %d\n", id, <-ch)

}

}

func main() {

ch := make(chan int)

for i := 0; i < 3; i++ {

go compute(i, ch)

}

for i := 0; i < 15; i++ {

ch <- i

}

}

在上述代码中,我们创建了三个 Goroutine,每个 Goroutine 将处理从 ch 中读取的值,然后将结果打印到控制台。注意到 Goroutine 的执行时间很短,因此调度器可以在它们的执行过程中进行上下文切换,从而保证无缝的并发执行。

4.2 避免通道阻塞

通道阻塞是一种常见的并发编程问题。为了解决这个问题,我们要么确保 Goroutine 可以收到通道中的数据,要么保证通道中有值可以被 Goroutine 获取。

下面是一个调整 channel 大小的例子:

func main() {

ch := make(chan int, 3)

go func() {

for {

fmt.Println(<-ch)

}

}()

for i := 0; i < 5; i++ {

ch <- i

}

time.Sleep(time.Second)

}

在上述代码中,我们创建了一个 buffer 为 3 的 channel,然后创建了一个 Goroutine,它会打印 channel 中被发送的值。但是在主 Goroutine 中发送了 5 个值之后,程序会睡眠一秒钟再结束。注意到 channel 的 buffer 容量高于 0,因此主 Goroutine 不会因为阻塞而挂起。同时,在 Goroutine 中,我们使用了死循环,可以持续不断消费 channel 中的数据。

4.3 解决资源争用

资源争用通常通过互斥量和条件变量来解决。这两个结构被用于协调多个 Goroutine 对共享资源的访问。

下面是一个使用互斥锁解决 Goroutine 计数器问题的例子:

var counter int

var mu sync.Mutex

func main() {

wg := sync.WaitGroup{}

for i:=0; i<10; i++ {

wg.Add(1)

go func() {

mu.Lock()

counter++

mu.Unlock()

wg.Done()

}()

}

wg.Wait()

fmt.Println("Counter:", counter)

}

在上面的示例代码中,我们使用了 sync.Mutex 来锁定对共享变量 counter 的访问。由于只有一个 Goroutine 能够获取互斥锁,因此不会出现竞争问题,而且计数器的值始终正确。

总结

本文详细介绍了 Goroutine 的工作原理、优点以及可能导致 Goroutine 挂起的原因,同时也提供了解决问题的途径和方法。

学习正确使用 Goroutine 可以让您的程序在运行大量并发操作时保持稳定,同时也能提高程序的性能和响应速度。在进行 Goroutine 编写时,需要注意代码中的并发访问问题以及程序对共享资源的使用。

后端开发标签