所有较为复杂的程序都会出现错误。当你意识到程序中存在一些异常情况时,启动调试会话通常不是你应该做的第一件事。本文将介绍一些无须使用调试器即可进行故障排除的技术。你可能会发现,尤其是在处理并发程序时,调试器有时并不能提供太多帮助,更有效的解决方案依赖于仔细阅读代码、日志和理解堆栈信息。

1. 解读堆栈跟踪信息

        如果幸运的话,当出现问题时,你的程序会出现恐慌,并输出大量诊断信息。之所以说你很幸运,是因为如果你有一个恐慌程序的输出,那么通常只需将它与源代码一起查看就可以找出问题所在。

        现在让我们来仔细研究一些堆栈跟踪信息。

1.1 哲学家进餐程序的死锁问题

        我们要讨论的第一个例子是哲学家进餐问题的一个容易出现死锁的实现,该程序中只有两个哲学家:

package main

import (
	"sync"
)

func philosopher(firstFork, secondFork *sync.Mutex) {
	for {
		firstFork.Lock()
		secondFork.Lock()
		secondFork.Unlock()
		firstFork.Unlock()
	}
}

func main() {
	forks := [2]sync.Mutex{}
	go philosopher(&forks[1], &forks[0])
	go philosopher(&forks[0], &forks[1])
	select {}
}

        由于嵌套锁的循环性质,该程序最终会死锁。当发生这种情况时,运行时会检测到程序中没有剩余的活动 goroutine 并输出堆栈跟踪信息。

        该堆栈跟踪信息从根本原因开始:

fatal error: all goroutines are asleep - deadlock!

        然后它列出了所有活动的 goroutine,从引起恐慌的 goroutine 开始。在死锁的情况下,这可以是任何一个死锁的 goroutine。

        以下堆栈跟踪信息从 main 中的空 select 语句开始。它表明有一个 goroutine 正在等待该 select 语句:

goroutine 1 [select (no cases)]:
main.main()

第二个 goroutine 堆栈信息显示了该 goroutine 遵循的路径:

        可以看到,第一个条目来自运行时包,即未到出的 runtime_SemacquireMutex 函数,该函数使用了 3 个参数进行调用。带问号显示的参数是运行时无法可靠捕获的值,因为它们是在寄存器中传递的,而不是被推到堆栈上的。

1.2 链表指针问题

        现在让我们来看一个更有趣的恐慌。以下程序包含一个竞争条件,并且偶尔会出现恐慌:

package main

import (
	"container/list"
	"math/rand"
	"sync"
)

// This program occasionally panics
func main() {
	wg := sync.WaitGroup{}
	wg.Add(2)
	ll := list.New()
	// Goroutine that fills the list
	go func() {
		defer wg.Done()
		for i := 0; i < 1000000; i++ {
			ll.PushBack(rand.Int())
		}
	}()
	// Goroutine that empties the list
	go func() {
		defer wg.Done()
		for i := 0; i < 1000000; i++ {
			if ll.Len() > 0 {
				ll.Remove(ll.Front())
			}
		}
	}()
	wg.Wait()
}

        该程序包含两个 goroutine: 一个可以将元素添加到共享链表的末尾,另一个则从链表的开头删除元素。该程序通常可以正常运行直至完成,但有时它也会出现恐慌,其堆栈跟踪信息如下所示:

        正如我们试图在这里所演示的那样,充分了解发生恐慌的情况始终是最明智的。大多数时候,这需要你研究并找出有关底层数据结构的假设。就像上面的链表示例一样,如果数据结构被编写为不能有 nil 指针,那么当你看到 nil 指针时,所考虑的就不应该是添加 nil 检查,而是要尝试理解为什么最终会出现 nil 指针。

2. 检测故障并修复

        尽管付出了努力来进行测试,但大多数软件系统还是会出现问题。这表明通过测试可以实现的目标是有限的。之所以有限主要源于有关复杂软件系统的几个事实:

        首先,任何较为复杂的系统都需要与其环境进行交互,枚举系统运行的所有可能环境是不切实际的(并且在许多情况下这完全就是不可能的)。

        其次,你也许可以测试某个系统以确保其按预期运行,但通过测试以确保系统不会出现意外行为则要困难得多。

        此外,并发也增加了额外的复杂性:在特定场景下测试成功的程序在投入生产环境时可能会在相同场景下失败。

2.1 正确认识失败

        坦率地说,无论你对程序进行过多少次测试,所有足够复杂的程序最终都会失败。因此,构建系统以实现优雅的故障处理和快速恢复是有意义的。该架构的一部分用于检测异常并在可能的情况下修复异常的基础设施。云

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部