如何优雅的使用Goroutine

1. Processes and Threads

操作系统会为该应用程序创建一个进程。作为一个应用程序,它像一个为所有资源而运行的容器。这些资源包括内存地址空间、文件句柄、设备和线程。
线程是操作系统调度的一种执行路径,用于在处理器执行我们在函数中编写的代码。一个进程从一个线程开始,即主线程,当该线程终止时,进程终止。这是因为主线程是应用程序的原点。然后,主线程可以依次启动更多的线程,而这些线程可以启动更多的线程。
无论线程属于哪个进程,操作系统都会安排线程在可用处理器上运行。每个操作系统都有自己的算法来做出这些决定。

2. Goroutines and Parallelism

Go 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutines 在绑定到单个操作系统线程的逻辑处理器中运行(P)。即使使用这个单一的逻辑处理器和操作系统线程,也可以调度数十万 goroutine 以惊人的效率和性能并发运行。

Concurrency is not Parallelism.
                                                                                                 --Rob Pike

并发不是并行。并行是指两个或多个线程同时在不同的处理器执行代码。如果将运行时配置为使用多个逻辑处理器,则调度程序将在这些逻辑处理器之间分配 goroutine,这将导致 goroutine 在不同的操作系统线程上运行。但是,要获得真正的并行性,您需要在具有多个物理处理器的计算机上运行程序。否则,goroutines 将针对单个物理处理器并发运行,即使 Go 运行时使用多个逻辑处理器。

3. Keep yourself busy or do the work yourself

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "Hello Gopher")
	})
	go func() {
		if err := http.ListenAndServe(":8080", nil); err != nil {
			log.Fatal(err)
		}
	}()
	select {}
}

举一个例子,用go实现一个http的服务,监听端口请求的任务交给一个goroutine处理,主进程用select{}阻塞,这样goroutine就可以处理来自8080端口的请求。从功能实现的角度来说,这样的写法确实达到了我们的目的。但是因为http.ListenAndServe是交给goroutine执行的,而且一旦出现了问题,main函数无法感知到,所以这样的实现是有缺点的。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "Hello Gopher")
	})
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

换了一种方式,不用goroutine去监听,直接在main中,相较于上一种实现,我们解决了上面的👆问题,main可以感知到http.ListenAndServe的错误。但是又来了一个问题,log.Fatal,熟悉它的小伙伴肯定知道,这个方法其实底层的实现就是os.Exit(),这就意味着直接就被kill掉,即便你在代码中有defer 的逻辑,也不会被执行,换句话说就是没有达到一个平滑的退出。这两个例子使我们遇到了两个问题

  1. 如何是程序平滑退出

  2. 如何让最外层的main可以感知到异常

Keep yourself busy or do the work yourself实际上第二端代码就已经提现了这个思想,不再将http.ListenAndServe通过goroutine的方式委派出去,http.ListenAndServe底层实际上是一个loop,而且是一个死循环,在不断的accept,这个就是我们这里想要说的busy work,而在main函数执行体现了do the work yourself```。Keep yourself busy or do the work yourself确实没有帮我们解决第一个问题,如何平滑退出。

4. Never start a goroutine without knowning when it will stop

在这个例子中,goroutine 泄漏可以在 code review 快速识别出来。不幸的是,生产代码中的 goroutine 泄漏通常更难找到。我无法说明 goroutine 泄漏可能发生的所有可能方式,您可能会遇到:

func leak() {
	ch := make(chan int)
	go func() {
		val := <-ch
		fmt.Println("We received a value:", val)
	}()
}

这个例子其实很明显了 goroutine完全就没有人能够管控他的生命周期,最后就会变成一个野生的goroutine,发展到最后就变成了goroutine泄露。再举一个例子:

package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello Gopher")
	})
	go http.ListenAndServe("0.0.0.0:8081", http.DefaultServeMux)
	http.ListenAndServe("0.0.0.0:8080", mux)
}

这个简单的应用程序在两个不同的端口上提供 http 流量,端口8080用于应用程序流量,端口8001用于访问 /debug/pprof 端点。由于存在两个http.ListenAndServe,如果不使用goroutine必将导致blocking,结果就是第二个http.ListenAndServe无法执行。然后使用了goroutine又会出现在Keep yourself busy or do the work yourself中遇到的问题,怎么感知到错误并且优雅退出。

Any time you start a Goroutine you must ask yourself:
1. When will it terminate?
2. What could prevent it from terminating?
只要你使用goroutine你就要问你自己两个问题
1. 什么时候goroutine会结束
2. 怎么让goroutine结束
                                                                                              -- Jacob Walker

原文link

我们期待可以在主进程和goroutine之间大家可以相互感知到error并优雅退出,所以我们需要对代码做一些修改。

通过将 serveApp 和 serveDebug 处理程序分解为各自的函数,我们将它们与main.main 解耦,我们还遵循了上面的建议,并确保 serveApp 和 serveDebug 将它们的并发性留给调用者。
如果 serveApp 返回,则 main.main 将返回导致程序关闭,只能靠类似 supervisor 进程管理来重新启动。

package main

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

func main() {
	go serverDebug()
	serverApp()
}

func serverApp() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello Gopher")
	})
	http.ListenAndServe("0.0.0.0:8080", mux)
}

func serverDebug() {
	http.ListenAndServe("0.0.0.0:8081", http.DefaultServeMux)
}

然而,serveDebug 是在一个单独的 goroutine 中运行的,如果它返回,那么所在的 goroutine 将退出,而程序的其余部分继续运行。由于 /debug 处理程序很久以前就停止工作了,所以其他同学会很不高兴地发现他们无法在需要时从您的应用程序中获取统计信息。继续修改

package main

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

func main() {
	go serverDebug()
	go serverApp()
	select {}
}

func serverApp() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello Gopher")
	})
	if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil {
		log.Fatal(err)
	}
}

func serverDebug() {
	if err := http.ListenAndServe("0.0.0.0:8081", http.DefaultServeMux); err != nil {
		log.Fatal(err)
	}
}

log.Fatal 调用了 os.Exit,会无条件终止程序;defers 不会被调用到。所以log.Fatal 的正确使用方法应该是在main函数中或者·是在init函数中使用。这次修改将异步执行的逻辑全部主动交给goroutine去执行,发起调用的人还是main自己,符合Keep yourself busy or do the work yourself但是对于异常处理还是不够平滑,换句话说就是没有完全管控好goroutine的生命周期,不知道goroutine啥时候会结束。

Only use log.Fatal from main.main or init functions.
仅限于在main或者在init函数中使用log.Fatal.

最后我们想要达到的要求是不管是serverApp还是说serverDebug任何一个出了问题,两个goroutine都要结束返回,按照这个思路我们自然而然的就会想到用chan去做一个消息同步。

func serve(addr string, handler http.Handler, stop <-chan struct{}) error {
	s := http.Server{
		Addr:    addr,
		Handler: handler,
	}

	go func() {
		<-stop // wait for stop signal
		s.Shutdown(context.Background())
	}()
	return s.ListenAndServe()
}

大体的思路就是启一个goroutine从chan中等待stop的信号,拿到之后就立马执行http.ShutDown()

所以经过一番整理,我最后的代码变成了

package main

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

func main() {
	done := make(chan error, 2)
	stop := make(chan struct{})
	go func() {
		done <- serverDebug(stop)
	}()
	go func() {
		done <- serverDebug(stop)
	}()
	var stopped bool
	for i := 0; i < cap(done); i++ {
		if err := <-done; err != nil {
			fmt.Println("err is:", err)
		}
		if !stopped {
			stopped = true
			close(stop)
		}
	}
}

func serverApp(stop chan struct{}) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello Gopher")
	})
	s := http.Server{
		Addr:    "0.0.0.0:8080",
		Handler: mux,
	}
	go func() {
		<-stop // wait for stop signal
		s.Shutdown(context.Background())
	}()
	return s.ListenAndServe()
}

func serverDebug(stop chan struct{}) error {
	s := http.Server{
		Addr:    "0.0.0.0:8081",
		Handler: http.DefaultServeMux,
	}
	go func() {
		<-stop // wait for stop signal
		s.Shutdown(context.Background())
	}()
	return s.ListenAndServe()
}

由于close()方法会主动唤醒goroutine所以goroutine就可以主动的去执行Shutdown方法,从而实现平滑退出。

我们来仔细的分析一下,结合本小结的核心内容--Never start a goroutine without knowning when it will stop简单的说就是如何管控好goroutine的生命周期,上面的代码serverAppserverDebug都是以goroutine的方式并行执行的,并且他们中都有一个去异步执行shutdown操作的goroutine,serverAppserverDebug这俩的生命周期由执行shutdown的goroutine管控,shutdown的goroutine本质上由外界的stop信号量管控然后serverAppserverDebug都是由main函数主动发起的,所以比较符合goroutine的使用规范。

再讲一个如何让blocking许久的goroutine退出的例子,核心就是要掌握超时控制

现在有一个查询然后返回结果的场景,search 函数是一个模拟实现,用于模拟长时间运行的操作,如数据库查询或 rpc 调用。在本例中,硬编码为200ms。定义了一个名为 process 的函数,接受字符串参数,传递给 search。对于某些应用程序,顺序调用产生的延迟可能是不可接受的。

func search(term string) (string, error) {
	time.Sleep(200 * time.Second)
	return "some value", nil
}

func process(term string) error {
	record, err := search(term)
	if err != nil {
		return err
	}
	fmt.Println("Received:", record)
	return nil
}

再者如果是一个查询请求起一个goroutine然后一直查询不到结果就一直blocking,最后又演变成了goroutine泄露。既然是拿不到查询的结果我们总不可能伪造一个结果返回,只能是设置一个时间,做一个超时控制。配合context和select,实现一个完美的超时控制。

	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel() 
	ch:=make(chan string)
	.....   //省略一些逻辑,这只是伪代码示意一下
	ch <- result // 从search方法返回的结果塞入channel
	....
	select{
		case <-ctx.Done():
		return  errors.New("search canceled")
		case result:= <-ch:
		fmt.Println("Received:",result)
	}

管控goroutine生命周期并且要能知道怎么让goroutine退出,这是本小结的核心!!重要的事情要多讲几次。

5. Leave concurrency to the caller

将并发行为交给调用者,其实这个思想在上一个讲述控制goroutine生命周期的小结里面其实已经有了体现,就是在main中去执行go func(){}()。现在我有这个一个场景,需要获取一个文件目录下所有的文件,现在有两种实现

func ListDirectory(dir string)([]string,error)
将目录读取到一个 slice 中,然后返回整个切片,或者如果出现错误,则返回错误。这是同步调用的,ListDirectory 的调用方会阻塞,直到读取所有目录条目。根据目录的大小,这可能需要很长时间,并且可能会分配大量内存来构建目录条目名称的 slice。
func ListDirectory(dir string)chan string
ListDirectory 返回一个 chan string,将通过该 chan 传递目录。当通道关闭时,这表示不再有目录。由于在 ListDirectory 返回后发生通道的填充,ListDirectory 可能内部启动 goroutine 来填充通道。

不过这里我要着重讲下ListDirectory chan 的问题

  • 通过使用一个关闭的通道作为不再需要处理的项目的信号,ListDirectory 无法告诉调用者通过通道返回的项目集不完整,因为中途遇到了错误。调用方无法区分空目录与完全从目录读取的错误之间的区别。这两种方法都会导致从 ListDirectory 返回的通道会立即关闭。
  • 调用者必须继续从通道读取,直到它关闭,因为这是调用者知道开始填充通道的 goroutine 已经停止的唯一方法。这对 ListDirectory 的使用是一个严重的限制,调用者必须花时间从通道读取数据,即使它可能已经收到了它想要的答案,这种情况下,如果goroutine还在不停的往chan塞数据的话,chan的空间也不是无限大的,就会导致chan blocking,然后外加goroutine的泄露,因为无法对go routine的生命周期进行管控。对于大中型目录,它可能在内存使用方面更为高效,但这种方法并不比原始的基于 slice 的方法快。

针对上述的问题引出本小结的核心观点,goroutine交给调用者,也就是说交给ListDirectory这个方法。我们根据这个思路去进行一下修改

func ListDirectory(dir string,fn func(string))

熟悉官方库的小伙伴看到这个函数声明一定会觉得很眼熟,没错,filepath.WalkDir 也是类似的模型,如果函数启动 goroutine,则必须向调用方提供显式停止该goroutine 的方法。通常,将异步执行函数的决定权交给该函数的调用方通常更容易。

这里我给出官方的example的链接大家看下就能马上反应过来,其实官方的代码写的确实是很好,很多技巧和套路都有涉及,还是要多看看官方的源码啊┭┮﹏┭┮。

6. 总结

go天生就具有并发的优势,语法层面也很简单,调用go关键字就能使用并发,但是你要用好goroutine要遵循三个原则

  • 把并发交给调用者

  • 管控好goroutine的生命周期

  • 如果有必要可以采取一些列的手段去终止goroutine,比如说利用context,利用channel等等

在使用goroutine前还是要刻意的去注意下这三点,这三点掌握了,go的并发算是掌握了使用,runtime的原理后面我们再仔细分析。

7. 下回预告

GoMemoryModel go内存模型

posted @ 2021-04-24 23:27  ttlv  阅读(617)  评论(0编辑  收藏  举报