CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 高效Go并发编程 说明
  • 1. 并发——高级概述
  • 2 Go并发原语
  • 3 Go内存模型
  • 4. 一些著名的并发问题
  • 5 工作池与管道
  • 6. 错误处理
  • 7 定时器和时钟
  • 8 并发处理请求
  • 9. 原子内存操作
  • 10 并发问题排查
    • 技术要求
    • 读取堆栈跟踪信息
    • 检测故障与修复
    • 调试异常情况
    • 总结
    • 延伸阅读
目录

10 并发问题排查

# 10 并发问题排查

所有较为复杂的程序都存在漏洞。当你意识到程序中存在一些异常情况时,启动调试会话通常并非首要之举。本章将介绍一些无需借助调试器即可排查问题的技巧。你可能会发现,尤其是在处理并发程序时,调试器有时并无太大帮助,解决问题更多地依赖于仔细研读代码、查看日志以及理解堆栈跟踪信息。

在本章中,我们将探讨以下内容:

  • 如何解读堆栈跟踪信息
  • 如何通过添加额外代码来监测程序行为,进而检测故障,有时还能修复程序
  • 如何利用超时机制和堆栈跟踪信息调试异常情况

# 技术要求

无。

# 读取堆栈跟踪信息

如果你运气好,程序在出错时会发生恐慌(panic),并打印出大量诊断信息。之所以说运气好,是因为若有恐慌程序的输出,通常结合源代码查看这些输出,就能找出问题所在。那么,让我们来看看一些堆栈跟踪信息。第一个示例是一个存在死锁风险的哲学家就餐问题的实现,这里只有两位哲学家:

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

func main() {
    forks := [2]sync.Mutex{}
    go philosopher(&forks[1], &forks[0]) // 第18行
    go philosopher(&forks[0], &forks[1]) // 第19行
    select {} // 第20行
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

由于嵌套锁的循环特性,这个程序最终会发生死锁。发生死锁时,运行时会检测到程序中没有活动的协程,并打印出堆栈跟踪信息。堆栈跟踪信息以根本原因开头(在这个例子中是死锁):

fatal error: all goroutines are asleep - deadlock!
1

然后,它会列出所有活动的协程,从导致恐慌的协程开始。在死锁的情况下,这个协程可以是任何一个陷入死锁的协程。下面的堆栈跟踪信息从main函数中的空select语句(第20行)开始,它表明有一个协程在等待这个select语句:

goroutine  1   [select   (no  cases)]:
main.main()
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/deadlock/main.go:20  +0xa5
1
2
3
4

第二个协程的堆栈展示了该协程的执行路径:

goroutine  17   [semacquire]:
sync .runtime_SemacquireMutex(0x0?,  0x1?,  0x0?)
/usr/local/go/src/runtime/sema.go:77  +0x25
sync . (*Mutex) .lockSlow(0xc00010e000)
/usr/local/go/src/sync/mutex.go:171  +0x165
sync . (*Mutex) .Lock( . . . )
/usr/local/go/src/sync/mutex.go:90
main.philosopher(0xc00010e008,  0xc00010e000)
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/deadlock/main.go:10  +0x66
created  by  main.main
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/deadlock/main.go:18  +0x65
1
2
3
4
5
6
7
8
9
10
11
12
13

你可以看到,第一个条目来自运行时包中未导出的runtime_SemacquireMutex函数,它带有三个参数。显示为问号的参数是运行时无法可靠捕获的值,因为它们是通过寄存器传递的,而不是压入堆栈。我们可以确定至少第一个参数是不正确的,因为查看堆栈跟踪信息中打印的源代码(.../go/src/runtime/sema.go:77),它应该是一个uint32值的地址。(如果你自己测试这段代码,行号可能与这里显示的不匹配。关键在于,你仍然可以通过查看你环境中打印的行号来检查Go标准库函数和你自己的函数。)这个函数由Mutex.lockSlow调用。查看源代码会发现,Mutex.lockSlow不接受任何参数,但堆栈跟踪信息显示有一个参数。这个参数是lockSlow方法的接收者,也就是调用该方法的互斥锁的地址。所以在这里,我们可以看到这次调用的互斥锁地址是0xc00010e00。再看下面的条目,我们发现这个方法是由Mutex.Lock调用的。下一个条目显示了在我们的程序中Mutex.Lock的调用位置:第10行,对应secondFork.Lock这一行。堆栈跟踪信息中的下一个条目还显示这个协程是由main函数在第18行创建的。

留意传递给函数的参数,我们看到main.philosopher函数有两个参数:两个互斥锁的地址。由于lockSlow方法接收的互斥锁地址是0xc00010e000,我们可以推断它是&forks[0]。所以,这个协程在尝试锁定&forks[0]时被阻塞了。

第三个协程的执行路径类似,但这次哲学家协程是在第19行启动的,对应第二位哲学家。按照类似的推理,你可以看到这个协程正在尝试锁定地址为0xc00010e008的互斥锁,也就是&forks[1]:

goroutine  18   [semacquire]:
sync .runtime_SemacquireMutex(0x0?,  0x0?,  0x0?)
/usr/local/go/src/runtime/sema.go:77  +0x25
sync . (*Mutex) .lockSlow(0xc00010e008)
/usr/local/go/src/sync/mutex.go:171  +0x165
sync . (*Mutex) .Lock( . . . )
/usr/local/go/src/sync/mutex.go:90
main.philosopher(0xc00010e000,  0xc00010e008)
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/deadlock/main.go:10  +0x66
created  by  main.main
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/deadlock/main.go:19  +0x9b
1
2
3
4
5
6
7
8
9
10
11
12
13

这个堆栈跟踪信息显示了死锁发生的位置。第一个协程正在等待锁定&forks[0],这意味着它已经锁定了&forks[1]。第二个协程正在等待锁定&forks[1],这意味着它已经锁定了&forks[0],于是就产生了死锁。

现在,让我们看一个更有意思的恐慌示例。下面这个程序存在竞态条件,偶尔会发生恐慌:

func main() {
    wg := sync.WaitGroup{}
    wg.Add(2)
    ll := list.New()
    // 填充链表的协程
    go func() {
        defer wg.Done()
        for i := 0; i < 1000000; i++ {
            ll.PushBack(rand.Int()) // 第18行
        }
    }()
    // 清空链表的协程
    go func() {
        defer wg.Done()
        for i := 0; i < 1000000; i++ {
            if ll.Len() > 0 {
                ll.Remove(ll.Front())
            }
        }
    }()
    wg.Wait()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

这个程序包含两个协程:一个将元素添加到共享链表的末尾,另一个从链表的开头移除元素。这个程序有时可能会正常运行结束,但有时会发生恐慌,并打印出如下的堆栈跟踪信息:

panic:  runtime  error:  invalid  memory  address  or  nil  pointer
dereference
[signal  SIGSEGV:  segmentation  violation  code=0x1  addr=0x0
pc=0x459570]

goroutine  17   [running]:
container/list. (*List) .insert( . . . )
/usr/local/go/src/container/list/list.go:96
container/list. (*List) .insertValue( . . . )
/usr/local/go/src/container/list/list.go:104
container/list. (*List) .PushBack( . . . )
/usr/local/go/src/container/list/list.go:152
main.main. func1()
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/listrace/main.go:18  +0x170
created  by  main.main
/home/bserdar/github.com/Writing-Concurrent-Programs-in-Go/
chapter10/stacktrace/listrace/main.go:15  +0xcd
exit  status  2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这是一个段错误(segmentation violation error),意味着程序试图访问它无权访问的内存部分。在这个例子中,恐慌信息显示地址为0x0,也就是说程序试图访问空指针的内容。堆栈跟踪信息展示了这是如何发生的:main.go的第18行调用了List.PushBack。从下往上查看堆栈跟踪信息,我们看到List.PushBack调用了List.insertValue,然后List.insertValue又调用了List.insert。空指针访问发生在List.insert的第96行,其源代码如下:

92: func (l *List) insert(e, at *Element) *Element {
93:    e.prev = at
94:    e.next = at.next
95:    e.prev.next = e
96:    e.next.prev = e  // 这里发生恐慌
97:    e.list = l
1
2
3
4
5
6

现在,进行一些简单的推理:如果e为nil,或者e.next为nil,第96行就会发生恐慌。查看源代码可知,e不可能为nil,否则在第96行之前就会发生恐慌。那么,一定是e.next为nil。这是不是标准库代码中的漏洞呢?毕竟这里的赋值操作没有进行空指针检查。

在这种情况下,多了解一些底层代码,比直接在代码中插入空指针检查更有帮助。查看源代码中的注释,你会发现:

// 为简化实现,链表l在内部被实现为一个环,
// 这样&l.root既是最后一个列表元素(l.Back())的下一个元素,
// 也是第一个列表元素(l.Front())的前一个元素。
1
2
3

由于链表被实现为一个环,next和prev指针不可能为nil。即使链表中只有一个节点,该节点的指针也会指向它自身。所以,一定是在代码的其他地方将这些指针设置为了nil。浏览代码中对nil值的赋值部分,我们发现了下面这段代码:

func (l *List) remove(e *Element) {
    e.prev.next = e.next
    e.next.prev = e.prev
    e.next = nil // 避免内存泄漏
    e.prev = nil // 避免内存泄漏
    e.list = nil
    l.len--
}
1
2
3
4
5
6
7
8

找到了!当从链表中移除一个元素时,会通过将其指针赋值为nil,使其与环分离。这种行为表明insert和remove操作存在并发运行的竞态条件。一个节点的next指针被List.remove设置为nil,但这个被移除的节点却被用作List.insert的参数,从而导致了恐慌。解决方案是创建一个互斥锁,并将所有链表操作放在由该互斥锁保护的临界区内(而不是添加空指针检查)。

正如我在这里试图展示的,充分理解恐慌发生的情况总是明智之举。大多数时候,这需要你去研究并了解底层数据结构的相关假设。就像前面链表的例子,如果数据结构设计为不允许有空指针,那么当你遇到空指针时,不应该添加空指针检查,而应该尝试理解为什么会出现空指针。

# 检测故障与修复

尽管在测试软件系统上投入了诸多努力,但大多数软件系统仍会出现故障。这表明测试的作用是有限的。这些限制源于复杂系统的几个事实。任何复杂系统都会与它的运行环境交互,要列举出系统可能运行的所有环境既不现实(在很多情况下也不可能)。此外,测试一个系统以确保其按预期运行通常是可行的,但要开发测试用例来确保系统不会出现意外行为则要困难得多。并发又增加了额外的复杂性:一个在特定场景下测试成功的程序,在投入生产时,面对相同场景却可能失败。

换句话说,无论你对程序进行多少测试,所有足够复杂的程序最终都可能出现故障。因此,为系统设计优雅的故障处理和快速恢复机制是很有必要的。这种架构的一部分是用于检测异常并在可能的情况下修复异常的基础设施。云计算和容器技术提供了许多检测程序故障的工具,以及用于重启程序的编排工具。对于基于传统可执行文件的非云部署,也有其他监控、警报和自动恢复工具可供使用。

其中一些异常是累积性的错误,会随着时间的推移逐渐显现,直至耗尽所有资源。内存泄漏和协程泄漏就属于这类故障。如果你注意到程序因内存不足错误而频繁重启,就应该排查内存泄漏或协程泄漏的问题。Go标准库提供了相关工具:

  • 使用runtime/pprof包为程序添加性能分析支持,并在受控环境中运行以重现泄漏问题。添加一个启用性能分析的标志是个不错的主意,这样无需重新编译就可以开启和关闭性能分析功能。你可以使用CPU或堆分析来确定泄漏的源头。
  • 使用net/http/pprof包通过HTTP服务器发布性能分析信息,这样你就可以在程序运行时观察其内存使用情况。

有时,这些异常并非程序本身的漏洞,而是由于对其他系统的依赖导致的。例如,你的程序可能依赖某个服务的响应,但该服务有时需要很长时间才能返回结果,甚至根本不返回。如果无法取消对该服务的调用,这就会成为一个大问题。大多数基于网络的系统最终都会超时,但这个超时时间可能对程序来说是不可接受的。还有可能是你的程序调用的某个服务或第三方库发生了挂起。一个可行的解决方案可能是优雅地终止程序,让编排系统启动程序的新实例。

第一个问题是检测故障。让我们想出一个较为复杂且贴近实际的例子:假设我们有一个程序调用SlowFunc函数,该函数有时需要很长时间才能完成。并且,假设无法取消这个函数的调用。但我们不希望程序无限期地等待SlowFunc的结果,所以我们设计了以下方案:

  • 如果对SlowFunc的调用在给定时间(CallTimeout)内成功完成,我们返回结果。
  • 如果对SlowFunc的调用持续时间超过CallTimeout,我们返回一个Timeout错误。由于无法取消SlowFunc,它会在一个单独的协程中继续运行,直至完成。
  • 可能会有很多对SlowFunc的调用都需要很长时间,所以我们希望将活动的并发调用数量限制在一定范围内。如果所有可用的并发调用都在等待SlowFunc完成,那么该函数应立即返回一个Busy错误。
  • 如果在给定的超时时间(AlertTimeout)内,最大数量的并发调用中没有一个返回响应,我们发出警报。

我们开始将这个方案开发为一个泛型类型Monitor[Req,Rsp any],其中Req和Rsp分别是请求和响应类型:

type Monitor[Req, Rsp any] struct {
    // 等待函数返回的时长
    CallTimeout  time.Duration
    // 当所有并发调用都在进行时,等待发出警报的时长
    AlertTimeout time.Duration
    // 警报通道
    Alert        chan struct{}
    // 我们正在监控的函数
    SlowFunc     func(Req) (Rsp, error)
    // 这个通道用于跟踪对SlowFunc的并发调用
    active  chan struct{}

    // 向这个通道发送信号意味着active通道已满
    full      chan struct{}
    // 当SlowFunc的一个实例返回时,它将生成一个心跳信号
    heartBeat  chan struct{}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Monitor.Call函数通过实现前面描述的超时机制来调用Monitor.SlowFunc。这个函数可能返回三种值之一:带或不带错误的有效响应、在Monitor.CallTimeout之后返回的超时错误,或者立即返回的Busy错误:

func (mon *Monitor[Req, Rsp]) Call(ctx context.Context, req Req) (Rsp, error) {
    var (
        rsp Rsp
        err error
    )
    // 如果监控器无法接受新的调用,立即返回ErrBusy,但也要启动警报定时器
    select {
    case mon.active <- struct{}{}:
    default:
        // 启动警报定时器
        select {
        case mon.active <- struct{}{}:
        case mon.full <- struct{}{}:
            return rsp, ErrBusy
        default:
            return rsp, ErrBusy
        }
    }

    // 在一个单独的goroutine中调用函数
    complete := make(chan struct{})
    go func() {
        defer func() {
            // 通知监控器函数已返回
            <-mon.active
            select {
            case mon.heartBeat <- struct{}{}:
            default:
            }
            // 通知调用者调用已完成
            close(complete)
        }()
        rsp, err = mon.SlowFunc(req)
    }()
    // 等待结果或超时
    select {
    case <-time.After(mon.CallTimeout):
        return rsp, ErrTimeout
    case <-complete:
        return rsp, err
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

让我们剖析一下这个方法。进入该方法时,它会尝试向mon.active发送数据。这是一个非阻塞发送操作,所以只有当活动的并发调用数量小于最大允许数量时才会成功。如果已经有最大数量的并发调用在进行中,则会选择default分支,该分支会再次尝试向mon.active发送数据。这是一种在通道之间模拟优先级的常见方式,这里mon.active具有更高的优先级。如果无法向mon.active发送数据,那么它会尝试向mon.full发送数据。只有当有一个goroutine正在等待从mon.full接收数据时,这个操作才会成功,稍后我们会明白,只有当mon.active已满但警报定时器尚未启动时,才会出现这种情况。如果定时器已经启动,控制警报定时器的goroutine将不会监听这个通道,所以会选择default分支。如果发生这种情况,调用将返回ErrBusy。如果向mon.full发送数据成功,那么这次调用将启动定时器,并且它也会返回ErrBusy。

第二部分是对mon.SlowFunc的实际调用,这是在一个单独的goroutine中完成的。由于没有办法取消mon.SlowFunc,这个goroutine只有在mon.SlowFunc返回时才会返回。如果mon.SlowFunc返回,会发生几件事:首先,从mon.active接收数据会从其中移除一个条目,这样监控器就可以接受另一个调用。其次,向mon.heartBeat通道进行非阻塞发送操作将停止警报定时器。这是一个非阻塞发送操作,因为稍后会看到,如果警报定时器处于活动状态,向mon.heartBeat发送数据将成功;如果警报定时器未处于活动状态,goroutine不会监听mon.heartBeat通道。

在最后一部分,我们等待mon.SlowFunc的结果。如果complete通道关闭,那么我们就有了准备好的结果,可以返回它们。如果先发生超时,那么我们返回ErrTimeout。

如果mon.SlowFunc在此之后返回(这很可能会发生),结果将被丢弃。有趣的部分是监控器goroutine本身,它包含在NewMonitor函数中:

func NewMonitor[Req, Rsp any](callTimeout time.Duration,
    alertTimeout time.Duration,
    maxActive int,
    call func(Req) (Rsp, error)) *Monitor[Req, Rsp] {
    mon := &Monitor[Req, Rsp]{
        CallTimeout:  callTimeout,
        AlertTimeout: alertTimeout,
        SlowFunc:     call,
        Alert:        make(chan struct{}, 1),
        active:       make(chan struct{}, maxActive),
        Done:         make(chan struct{}),
        full:         make(chan struct{}),
        heartBeat:    make(chan struct{}),
    }

    go func() {
        var timer *time.Timer
        for {
            if timer == nil {
                select {
                case <-mon.full:
                    timer = time.NewTimer(mon.AlertTimeout)
                case <-mon.Done:
                    return
                }
            } else {
                select {
                case <-timer.C:
                    mon.Alert <- struct{}{}
                case <-mon.heartBeat:
                    if!timer.Stop() {
                        <-timer.C
                    }
                case <-mon.Done:
                    return
                }
                timer = nil
            }
        }
    }()

    return mon
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

这个goroutine有两种状态:一种是timer==nil时,另一种是timer不为nil时。当timer==nil时,意味着正在进行的并发调用少于maxActive个,所以不需要警报定时器。在这种状态下,我们监听mon.full通道。如前所述,如果有一个调用向mon.full通道发送数据,监控器会创建一个新的定时器,我们就进入第二种状态。在第二种状态下,我们监听定时器通道和heartBeat通道(而不是mon.full通道,所以在mon.Call中进行非阻塞发送是必要的)。如果mon.heartBeat信号先于定时器到达,我们停止定时器,并将其设置为nil,使goroutine再次进入第一种状态。如果定时器先触发,我们发出警报。

要使用这个监控器,你必须先初始化它,然后通过监控器调用SlowFunc:

// 使用50毫秒的调用超时、5秒的警报超时和最多10个并发调用初始化监控器。
// 目标函数是SlowFunc
var myMonitor = NewMonitor[*Request,*Response](50*time.Millisecond,5*time.Second,10,SlowFunc)
1
2
3

我们必须设置一个goroutine来处理警报:

go func() {
    for {
        select {
        case <-myMonitor.Alert:
            // 处理警报
        case <-myMonitor.Done:
            return
        }
    }
}()
1
2
3
4
5
6
7
8
9
10

然后,通过监控器调用目标函数:

response, err := myMonitor.Call(request)
1

在这种情况下,从错误中恢复实际上并没有太多办法。当触发警报时,你可以发送电子邮件或聊天消息来通知相关人员,或者简单地打印日志并终止程序,以便重新启动一个新的进程。

有时,重启一个出现故障的goroutine是有意义的。当一个goroutine长时间无响应时,一个简单的监控器可以取消该goroutine并创建一个新的实例:

func restart(done chan struct{}, f func(done, heartBeat chan struct{}), timeout time.Duration) {
    for {
        funcDone := make(chan struct{})
        heartBeat := make(chan struct{})
        // 在一个新的goroutine中启动函数
        go func() {
            f(funcDone, heartBeat)
        }()
        // 期望在超时之前收到心跳信号
        timer := time.NewTimer(timeout)
        retry := false
        for!retry {
            select {
            case <-done:
                close(funcDone)
                return
            case <-heartBeat:
                // 心跳信号在超时之前到达,重置定时器
                if!timer.Stop() {
                    <-timer.C
                }
                timer.Reset(timeout)
            case <-timer.C:
                fmt.Println("Timeout, restarting func")
                // 尝试停止当前函数
                close(funcDone)

                // 跳出for循环,以便启动一个新的goroutine
                retry = true
            }
        }
    }
}
...
// 以100毫秒的超时运行longRunningFunc
restart(doneCh, longRunningFunc, 100*time.Millisecond)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

如果longRunningFunc未能每100毫秒发送一次心跳信号,这将重启longRunningFunc。这可能是因为longRunningFunc失败了,或者是因为它在等待另一个无响应的长时间运行的进程。

# 调试异常情况

并发算法有一种奇怪的现象,在被观察时能正常工作,而在未被观察时却会出错。很多时候,一个在调试器中运行良好的程序,在生产环境中却会神秘地出错。有时,这种错误会附带堆栈跟踪信息,你可以据此追溯错误发生的原因。但有时,错误更加难以察觉,没有明确的迹象表明哪里出了问题。

考虑上一节中的监控器。你可能想知道为什么SlowFunc会出现异常。你不能在调试器中真正运行它并逐行调试代码,因为你根本无法控制函数的哪次调用会挂起。但是你可以在问题发生时打印堆栈跟踪信息。这是并发程序中大多数异常的特点:你不知道它何时会发生,但通常能判断它确实发生了。所以,你可以打印各种诊断信息来追溯程序是如何走到这一步的。例如,你可以在监控器发出警报时打印堆栈跟踪信息:

import (
    "runtime/pprof"
   ...
)
...
go func() {
    select {
    case <-mon.Alert:
        pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)
    case <-mon.Done:
        return
    }
}()
1
2
3
4
5
6
7
8
9
10
11
12
13

这将在发出警报时给出所有goroutine的堆栈跟踪信息,这样你就可以看到哪些goroutine调用了SlowFunc,以及它在等待什么。

如果死锁涉及所有活动的goroutine,那么检测死锁很容易。当运行时意识到没有goroutine可以继续执行时,它会打印堆栈跟踪信息并终止程序。但如果至少有一个goroutine仍在运行,情况就没那么简单了。一个常见的场景是服务器在处理请求时导致死锁,而死锁只涉及少数几个goroutine。由于死锁没有阻塞所有的goroutine,运行时永远不会检测到这种情况,并且处于死锁中的所有goroutine都会泄漏。如果你怀疑存在这种泄漏,添加一些检测代码来诊断问题可能是有意义的:

func timeoutStackTrace(timeout time.Duration) (done func()) {
    completed := make(chan struct{})
    done = func() {
        close(completed)
    }
    go func() {
        select {
        case <-time.After(timeout):
            pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)
        case <-completed:
            return
        }
    }()
    return
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

timeoutStackTrace函数会一直等待,直到done函数被调用或者超时发生。如果发生超时,它会打印所有活动goroutine的堆栈跟踪信息,这样你就可以尝试找出超时的原因。它可以这样使用:

func (svc MyService) handler(w http.ResponseWriter, r *http.Request) {
    // 如果调用在20秒内未完成,打印堆栈跟踪信息
    defer timeoutStackTrace(20*time.Second)()
   ...
}
1
2
3
4
5

如你所见,如果你怀疑存在死锁或goroutine无响应等问题,在检测到这种情况后打印堆栈跟踪信息可能是一种有效的故障排除方法。

处理竞态条件(race condition)通常更困难。最好的做法通常是开发一个单元测试,重现你怀疑存在竞态的场景,并使用Go语言的竞态检测器(使用-race标志)运行它。竞态检测器会在程序中添加必要的检测代码来验证内存操作,并在检测到内存竞态时报告。由于它依赖于代码检测,竞态检测器只能在竞态发生时检测到。这意味着,如果竞态检测器报告存在竞态,那么就确实存在竞态;但如果它没有报告竞态,并不意味着不存在竞态。所以,确保你运行竞态检测测试一段时间,以增加发现竞态的机会。许多竞态条件会表现为数据结构损坏,就像我在本章前面展示的列表示例一样。这将需要你仔细阅读大量代码(包括阅读标准库或第三方库的代码),以确定问题的根源是竞态条件。然而,一旦你意识到你处理的是竞态条件而不是其他错误,当你检测到不应该发生的事情发生时,可以在代码的关键位置插入fmt.Printf或panic语句。

# 总结

本章展示了一些对调试并发程序有用的技术。处理这类程序的关键思路是,通常只有在糟糕的事情发生之后,你才能察觉。所以,添加能生成带有诊断信息的警报的额外代码可能会很有帮助。很多时候,这仅仅是额外的日志记录、Printf语句和panic(也称为“穷人的调试器”)。在你的程序中添加这样的代码,并在生产环境中保留这些代码。立即失败几乎总是比错误的计算要好。

# 延伸阅读

Go开发环境提供了许多诊断工具:

  • Go单元测试框架:https://pkg.go.dev/testing
  • runtime/pprof包使你的程序内部信息可用于监控工具:https://pkg.go.dev/runtime/pprof
  • Go竞态检测器可确保你的代码无竞态:https://go.dev/blog/race-detector
  • Go性能分析器是分析泄漏和性能瓶颈不可或缺的工具:https://go.dev/blog/pprof
上次更新: 2025/04/08, 19:40:35
9. 原子内存操作

← 9. 原子内存操作

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式