go语言面试之Goroutine详解

发布于:2025-09-04 ⋅ 阅读:(19) ⋅ 点赞:(0)

在Go语言的面试中,Goroutine 是一个非常重要的话题,因为它是Go语言并发编程的核心。Goroutine为我们提供了轻量级的线程,并且能够让程序在多核处理器上更高效地运行。在这篇文章中,我将详细讨论Goroutine的基本概念、如何使用它以及一些面试中常见的考察点。

一、Goroutine的基本概念

在Go语言中,Goroutine 是一种并发执行的轻量级线程。每个Goroutine都有独立的栈空间,默认情况下,Go的运行时系统会为每个Goroutine分配2KB的栈空间。栈空间是动态扩展的,这使得Go能够同时运行成千上万的Goroutine,而不会像传统线程那样占用大量的内存资源。

1. 什么是Goroutine泄漏

在生活中泄漏通常指的是某个容易的故障导致资源的浪费,比如汽车的油箱破了导致汽油泄漏,而在计算机领域,泄漏(Leakage)通常指的是某些进程/资源因为被遗忘而忘记回收,占用着系统的资源,最终导致系统资源枯竭而宕机。

在日常GO语言的编码中创建协程是很容易的,但是对goroutine生命周期的管理是不容易的。具体来说如果你创建了一个协程(goroutine),你以为这个goroutine最终会按照预期完成自己的任务然后结束执行,但是由于某些原因,这个goroutine一直没有终止,他占用着系统的资源永不释放,无人管控,最终可能因为大量存在这一类泄漏的协程,系统资源消耗殆尽,导致主程序意外终止或者系统崩溃。

2. 监控泄漏的goroutine

日常开发中遇到goroutine泄漏通常是比较难

2.1 goleak

github地址 https://github.com/uber-go/goleak

goleak 是一个 Go 语言的工具,用于检查 goroutine 泄漏的情况。它可以在测试代码中使用,自动检查测试结束时是否有 goroutine 泄漏,并将泄漏的 goroutine 信息输出。

下面是使用 goleak 检查 goroutine 泄漏的步骤:

  1. 安装goleak
go get -u golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
go get -u github.com/uber-go/goleak
  1. 我们编写一段会产生goroutine泄漏的代码
func LeakDemo1(){
	ch := make(chan int) // 创建一个无缓冲区的通道

	go func() {
		val := <-ch
		fmt.Println("哈哈哈哈",val)
	}()
}

然后我们在demo1目录下创建一个main_test文件,里面编写一下内容(有goLand这个IDE的可以直接在LeakDemo函数上右键 -> generate -> Test for fuction可以帮助我们快速创建Testing文件)

package main

import (
	"go.uber.org/goleak"
	"testing"
)
func TestLeakDemo1(t *testing.T) {
	defer goleak.VerifyNone(t)
	LeakDemo1()
}

这样,当测试用例结束时,goleak 会检查当前是否有 goroutine 泄漏,如果有,则会在控制台输出相关信息,帮助开发人员快速地定位和解决问题。

  1. 下面我们右键main_test文件运行

发现答案了,goleak帮我们寻找出来了对应的泄漏代码,如下图出现了 found unexpected goroutines 的字样,说明出现了协程泄漏。

VerifyTestMain的运行结果与VerifyNone有一点不同,VerifyTestMain会先报告测试用例执行结果,然后报告泄漏分析,如果测试的用例中有多个goroutine泄漏,无法精确定位到发生泄漏的具体test,需要使用如下脚本进一步分析:

# Create a test binary which will be used to run each test individually
$ go test -c -o tests

# Run each test individually, printing "." for successful tests, or the test name
# for failing tests.
$ for test in $(go test -list . | grep -E "^(Test|Example)"); do ./tests -test.run "^$test\$" &>/dev/null && echo -n "." || echo -e "\n$test failed"; done

需要注意的是,goleak 只能检查 goroutine 泄漏的情况,不能检查内存泄漏或者其他类型的资源泄漏。如果需要检查内存泄漏等问题,需要使用其他工具进行分析。

goleak实现原理

从VerifyNone入口,我们查看源代码,其调用了Find方法:

// Find looks for extra goroutines, and returns a descriptive error if
// any are found.
func Find(options ...Option) error {
  // 获取当前goroutine的ID
 cur := stack.Current().ID()

 opts := buildOpts(options...)
 var stacks []stack.Stack
 retry := true
 for i := 0; retry; i++ {
    // 过滤无用的goroutine
  stacks = filterStacks(stack.All(), cur, opts)

  if len(stacks) == 0 {
   return nil
  }
  retry = opts.retry(i)
 }

 return fmt.Errorf("found unexpected goroutines:\n%s", stacks)
}

我们在看一下filterStacks方法:

// filterStacks will filter any stacks excluded by the given opts.
// filterStacks modifies the passed in stacks slice.
func filterStacks(stacks []stack.Stack, skipID int, opts *opts) []stack.Stack {
 filtered := stacks[:0]
 for _, stack := range stacks {
  // Always skip the running goroutine.
  if stack.ID() == skipID {
   continue
  }
  // Run any default or user-specified filters.
  if opts.filter(stack) {
   continue
  }
  filtered = append(filtered, stack)
 }
 return filtered
}

这里主要是过滤掉一些不参与检测的goroutine stack,如果没有自定义filters,则使用默认的filters:

func buildOpts(options ...Option) *opts {
 opts := &opts{
  maxRetries: _defaultRetries,
  maxSleep:   100 * time.Millisecond,
 }
 opts.filters = append(opts.filters,
  isTestStack,
  isSyscallStack,
  isStdLibStack,
  isTraceStack,
 )
 for _, option := range options {
  option.apply(opts)
 }
 return opts
}

从这里可以看出,默认检测20次,每次默认间隔100ms;添加默认filters;

总结一下goleak的实现原理:

使用runtime.Stack()方法获取当前运行的所有goroutine的栈信息,默认定义不需要检测的过滤项,默认定义检测次数+检测间隔,不断周期进行检测,最终在多次检查后仍没有找到剩下的goroutine则判断没有发生goroutine泄漏。

3. 常见的Goroutine泄漏的原因

  • Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
  • Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
  • Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。

3.1 无缓冲区的channel

对于无缓冲区的channel来说,最经典的goroutine泄漏无非就是协程在监听无缓冲的channel,由于对于无缓冲区的channle来说,无论是从channel中读还是往channel中写数据,都会造成阻塞。所以很容易出现泄漏问题

A 从空缓存channel中recv数据

func leak() {
    ch := make(chan int)

    go func() {
        val := <-ch // 这行代码将永远被阻塞
        fmt.Println("We received a value:", val) // 这行代码永远不会执行
    }()
}

上面代码中,函数leak函数创建了一个无缓冲区channel,并且启动一个goroutine从ch管道中接收元素,由于ch管道是无缓冲管道并且没有任何协程往ch中发送数据,所以第5行代码 val := <- ch 将会永久阻塞,永远不会执行leak函数的第6行代码。

B 往空缓存channel里Send数据

同理,如果往一个无缓冲的channel中发送数据也会造成goroutine阻塞

func leak() {
    ch := make(chan int)

    go func() {
        val := <-ch // 这行代码将永远被阻塞
        fmt.Println("We received a value:", val) // 这行代码永远不会执行
    }()
}

但是没有管理好他的生命周期,导致这些协程被遗忘,让本该结束退出的协程因为没有管制而永远在后台隐秘的运行,并占用着系统的资源,直到系统资源枯竭,程序退出。

1.1 被遗忘的发送者

// search
// @Description: 定义一个搜索接口
// @param term :  搜索的关键字
// @return string : 搜索的返回结果
// @return error
func search(keyword string)(string,error){
	time.Sleep(time.Millisecond * 200) // 模拟业务执行搜索需要的时间 假设是200毫秒
	return "搜索结果",nil
}

type result struct{
	record string
	err error
}

func process(key string) error{
	ctx,cancel := context.WithTimeout(context.Background(),100*time.Millisecond) // 设置如果超过100毫秒没有返回就终止协程
	defer cancel()

	ch := make(chan result)

	// 开启协程,执行后台搜索
	go func() {
		record,err := search(key)
		ch <- result{
			record: record,
			err: err,
		}
	}()
	select{
	case <- ctx.Done():
		return errors.New("搜索超时,被动搜索") // 因为设置了100毫秒的限制
		case res :=<-ch:
			if res.err != nil{
				return res.err
			}
			fmt.Println("返回搜索结果",res.record)
			return nil
	}
}

整个函数的执行流程是这样的

由于超时时间不合理,加上无缓冲区管道,导致最终协程泄漏

使用有缓冲区通道

如果我们将无缓冲区通道修改为有缓冲区通道,那么

ctx,cancel := context.WithTimeout(context.Background(),100*time.Millisecond) // 设置如果超过100毫秒没有返回就终止协程
defer cancel()

ch := make(chan result,1)

现在,在超时情况下,在接收器移动之后,搜索Goroutine将通过将结果值放置在通道中完成发送,然后返回。该Goroutine的内存以及通道的内存最终将被回收。一切都会自然而然地解决。

1.2 半途而废的协程

在go语言中,有个规则:

程序先执行init函数,然后执行main函数,在main函数执行完成并返回时候,程序退出,不会等待其他任何子goroutine,所谓的子goroutine就是我们使用go关键字启动的协程

规范很清楚,当程序从主函数返回时,您的程序不会等待任何未完成的Goroutine执行完毕。这是一件好事!想想让一个Goroutine泄漏或让一个Goroutine运行很长时间是多么容易。如果您的程序在终止之前等待非主Goroutine完成,它可能会陷入某种僵尸状态,永远不会终止。

然而,当您启动一个Goroutine来做一些重要的事情,但主函数不知道等待它完成时,这种终止行为就会成为一个问题。这种类型的场景可能会导致完整性问题,例如损坏数据库、文件系统或丢失数据。

type Tracker struct {

}

func (t *Tracker) Event(data string){
	time.Sleep(time.Millisecond)
	log.Println(data)
}

type App struct{
	tracker Tracker
}

func(a *App)Handler(w http.ResponseWriter,r *http.Request){
	// 实际编写业务代码
	
	// 返回给客户端
	
	w.WriteHeader(http.StatusOK)
	
	go a.tracker.Event("记录埋点")
}

代码的重要部分是第21行。这就是在新Goroutine的范围内调用a.track.Event方法的地方。这具有异步跟踪事件而不增加请求延迟的预期效果。然而,这段代码落入了不完整的工作陷阱,必须进行重构。在第21行创建的任何Goroutine都不能保证运行或完成。这是一个完整性问题,因为服务器关闭时可能会丢失事件。

担保的重构

为了避免陷阱,团队修改了Tracker类型来管理Goroutines本身。该类型使用sync.WaitGroup来保持打开的Goroutine的计数,并为要调用的主函数提供Shutdown方法,该方法将等待所有Goroutine完成。

首先,处理程序被修改为不直接创建Goroutine。清单4中唯一的变化是它不再包含go关键字去异步调用。

func(a *App)Handler(w http.ResponseWriter,r *http.Request){
	// 实际编写业务代码
	
	// 返回给客户端
	
	w.WriteHeader(http.StatusOK)
	
	a.tracker.Event("记录埋点")
}

接下来,Tracker类型被重写以管理Goroutines本身。

type Tracker struct {
	wg sync.WaitGroup
}

// Event starts tracking an event. It runs asynchronously to
// not block the caller. Be sure to call the Shutdown function
// before the program exits so all tracked events finish.
func (t *Tracker) Event(data string) {
	t.wg.Add(1)
	go func() {
		defer t.wg.Done()
		time.Sleep(time.Millisecond) // Simulate network write latency.
		log.Println(data)
	}()
}
func(t *Tracker) Shutdown() {
	t.wg.Wait()
}

在清单5中,第12行将sync.WaitGroup添加到Tracker的类型定义中。在第21行的Event方法中,调用了t.wg.Add(1)。这会使计数器递增,以说明在第24行创建的Goroutine。一旦创建了Goroutine,Event函数就会返回满足客户端最小化事件跟踪延迟要求的结果。创建的Goroutine执行其工作,完成后在第27行调用t.wg.done()。调用Done方法会减少计数器,以便WaitGroup知道此Goroutine已完成。

对Add和Done的调用对于跟踪活动Goroutine的数量很有用,但程序仍必须被指示等待它们完成。为此,Tracker类型在第35行获得了一个新的方法Shutdown。此函数的最简单实现是调用t.wg.Wit(),该函数将一直阻塞,直到Goroutine计数减为0。最后,必须从func main调用此方法,如清单6所示:

func main() {
	
	// Start a server.
	// Details not shown...
	var a App
	
	// Shut the server down.
	// Details not shown...
	
	// Wait for all event goroutines to finish.
	a.track.Shutdown()
}

清单6的重要部分是第66行,它阻止func main终止,直到a.track.Shutdown()完成。

但也许不要等太久

Shutdown方法的实现很简单,可以完成所需的工作;它等待Goroutines完成。不幸的是,它等待的时间没有限制。根据您的生产环境,您可能不愿意无限期地等待程序关闭。为了给Shutdown方法添加截止日期,团队将其更改为:

清单7

// Shutdown waits for all tracked events to finish processing
// or for the provided context to be canceled.
func (t *Tracker) Shutdown(ctx context.Context) error {

    // Create a channel to signal when the waitgroup is finished.
    ch := make(chan struct{})

    // Create a goroutine to wait for all other goroutines to
    // be done then close the channel to unblock the select.
    go func() {
        t.wg.Wait()
        close(ch)
    }()

    // Block this function from returning. Wait for either the
    // waitgroup to finish or the context to expire.
    select {
    case <-ch:
        return nil
    case <-ctx.Done():
        return errors.New("timeout")
    }
}

现在在清单7的第38行,Shutdown方法接受context.context作为输入。这就是调用者如何限制允许关机等待的时间。在第41行的函数中,创建一个频道,然后在第45行启动一个Goroutine。这个新的Goroutine的唯一任务是等待WaitGroup完成,然后关闭频道。最后,行52开始一个选择块,它等待上下文被取消或频道被关闭。

接下来,团队将func main中的调用更改为:

清单8

// Wait up to 5 seconds for all event goroutines to finish.
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

err := a.track.Shutdown(ctx)

在清单8中,在主函数中创建了一个具有5秒超时的上下文。这将传递给.track.Shutdown以设置main愿意等待多长时间的限制。

结论

随着Goroutines的引入,该服务器的处理程序能够将需要跟踪事件的API客户端的延迟成本降至最低。只使用go关键字在后台运行这项工作很容易,但该解决方案存在完整性问题。要做到这一点,需要在关闭程序之前努力确保所有相关的Goroutine都已终止。

并发是一个有用的工具,但必须谨慎使用。

有人说可以使用pprof来排查,虽然其可以达到目的,但是这些性能分析工具往往是在出现问题后借助其辅助排查使用的,有没有一款可以防患于未然的工具吗?当然有,goleak他来了,其由 Uber 团队开源,可以用来检测goroutine泄漏,并且可以结合单元测试,可以达到防范于未然的目的,本文我们就一起来看一看goleak。

不知道你们在日常开发中是否有遇到过goroutine泄漏,goroutine泄漏其实就是goroutine阻塞,这些阻塞的goroutine会一直存活直到进程终结,他们占用的栈内存一直无法释放,从而导致系统的可用内存会越来越少,直至崩溃!简单总结了几种常见的泄漏原因:

  • Goroutine内的逻辑进入死循坏,一直占用资源
  • Goroutine配合channel/mutex使用时,由于使用不当导致一直被阻塞
  • Goroutine内的逻辑长时间等待,导致Goroutine数量暴增

接下来我们使用Goroutine+channel的经典组合来展示goroutine泄漏;

func GetData() {
 var ch chan struct{}
 go func() {
  <- ch
 }()
}

func main()  {
 defer func() {
  fmt.Println("goroutines: ", runtime.NumGoroutine())
 }()
 GetData()
 time.Sleep(2 * time.Second)
}

这个例子是channel忘记初始化,无论是读写操作都会造成阻塞,这个方法如果是写单测是检查不出来问题的:

func TestGetData(t *testing.T) {
 GetData()
}
=== RUN   TestGetData
--- PASS: TestGetData (0.00s)
PASS

内置测试无法满足,接下来我们引入goleak来测试一下。

goleak

github地址:https://github.com/uber-go/goleak

使用goleak主要关注两个方法即可:VerifyNone、VerifyTestMain,VerifyNone用于单一测试用例中测试,VerifyTestMain可以在TestMain中添加,可以减少对测试代码的入侵,举例如下:

使用VerifyNone:

func TestGetDataWithGoleak(t *testing.T) {
 defer goleak.VerifyNone(t)
 GetData()
}

运行结果

=== RUN   TestGetDataWithGoleak
    leaks.go:78: found unexpected goroutines:
        [Goroutine 35 in state chan receive (nil chan), with asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1 on top of the stack:
        goroutine 35 [chan receive (nil chan)]:
        asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1()
         /Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:12 +0x1f
        created by asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData
         /Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:11 +0x3c
        ]
--- FAIL: TestGetDataWithGoleak (0.45s)

FAIL

Process finished with the exit code 1

通过运行结果看到具体发生goroutine泄漏的具体代码段;使用VerifyNone会对我们的测试代码有入侵,可以采用VerifyTestMain方法可以更快的集成到测试中:

func TestMain(m *testing.M) {
 goleak.VerifyTestMain(m)
}
=== RUN   TestGetData
--- PASS: TestGetData (0.00s)
PASS
goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 5 in state chan receive (nil chan), with asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1 on top of the stack:
goroutine 5 [chan receive (nil chan)]:
asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1()
 /Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:12 +0x1f
created by asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData
 /Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:11 +0x3c
]

Process finished with the exit code 1

四、面试常见考察点

在Go语言面试中,关于Goroutine的考察通常会包括以下几个方面:

  1. Goroutine的创建与使用
    面试官可能会问你如何在Go中创建Goroutine,如何同步多个Goroutine的执行,如何确保主函数在所有Goroutine完成后再退出。

  2. 通道(Channel)的使用
    作为Go中非常重要的并发工具,通道的使用是面试中的常见问题。你可能会被要求用通道来解决一些常见的并发问题,比如Goroutine之间的数据传递、同步等。

  3. Goroutine的生命周期
    面试官可能会问你如何管理Goroutine的生命周期,尤其是如何避免Goroutine泄漏(Goroutine Leak),即某个Goroutine无法退出或被及时回收。

  4. 调度和性能
    你可能会被问到Go的调度机制,如何高效地利用多个CPU核心,如何通过调整 GOMAXPROCS 来影响调度器的行为,以及如何诊断和优化并发程序的性能。

五、Goroutine的性能优化

尽管Goroutine非常轻量,但它的性能仍然受到调度器的影响。如果Goroutine的数量过多,或者它们频繁进行上下文切换,可能会导致性能下降。以下是一些优化Goroutine性能的建议:

  1. 减少Goroutine的数量
    不要随意创建大量的Goroutine。创建过多的Goroutine会导致调度器的开销增加,从而影响程序的整体性能。

  2. 使用缓冲通道
    如果你需要在Goroutine之间传递大量数据,可以使用缓冲通道来减少阻塞和等待时间。

  3. 避免共享内存
    Go语言提倡通过通道而不是共享内存来进行Goroutine之间的通信。避免共享内存可以避免竞态条件,并使代码更加可预测。

  4. 合理使用 sync.Pool
    在需要频繁创建和销毁对象的场景下,可以考虑使用 sync.Pool 来重用对象,避免频繁的内存分配和垃圾回收开销。

六、总结

Goroutine是Go语言的核心特性之一,它为并发编程提供了非常高效且简洁的解决方案。在面试中,Goroutine相关的问题不仅考察你的并发编程能力,还考察你对Go语言内部机制的理解。通过掌握Goroutine的创建、同步、调度和性能优化技巧,能够在Go语言面试中表现得更加出色。

希望通过这篇文章,对Goroutine有了更加深入的理解,并能够在实际工作中灵活应用这些知识。如果正在准备Go语言面试,建议多做一些实际的并发编程练习,掌握如何有效地使用Goroutine来处理高并发问题。


网站公告

今日签到

点亮在社区的每一天
去签到