优雅的关闭golang程序

.

在任何需要持久化的进程中,优雅的关闭都很重要,特别是需要处理有状态的进程。优雅的关闭能让用户无感知,简化关闭的流程,减轻运维的压力。

什么时候我们可以让程序正常关闭

  • 所有挂起的进程,web,循环 都已经完成,不启动新的进程,也不应接受新的web请求
  • 关闭所有与外部服务和数据库的连接

反模式

人为阻塞

第一个反模式是阻塞go 主进程,而不实际等待任何东西。这是一个示例demo实现:


func KeepProcessAlive() {
	var ch chan int
	<-ch
}

func main() {

	...
	KeepProcessAlive()
}

os.Exit()

当其他的go routines仍然在运行的时候调用os.Exit(1),本质上就是调用SIGKILL。导致没有机会关闭打开的连接并完成正在处理中的请求。


go func() {
		<-ch		
		os.Exit(1)
}()

go func () {

	for ... {

	}
}()

Go中优雅的关闭

为了优雅的关闭服务,需要做到两件事:

  • 等待所有正在运行的goroutine退出
  • 将终止信号传播到多个goroutine

等待goroutine完成

Go提供了足够多的方法来控制并发。

最简单的方法是使用原生的channel。

  • 创建一个空结构体类型的channel make(chan struct{},1)
  • 每个child go routine 在完成后发布到这个channel
  • 父go routine消费和子go routine数目同样多的的次数
func run(ctx) {
  wait := make(chan struct{}, 1)

	go func() {
		defer func() {
	    wait <- struct{}{}
		}()
		for {
			select {
	    case <-ctx.Done():
				fmt.Println("Break the loop")
				break;
			case <-time.After(1 * time.Second):
				fmt.Println("Hello in a loop")
			}
		}
	}()

	go func() {
		defer func() {
	    wait <- struct{}{}
		}()
		for {
			select {
	    case <-ctx.Done():
				fmt.Println("Break the loop")
				break;
			case <-time.After(1 * time.Second):
				fmt.Println("Ciao in a loop")
			}
		}
	}()

	// wait for two goroutines to finish
	<-wait
	<-wait

	fmt.Println("Main done")
}

使用WaitGroup

上述的通道解决方案挺难使用,尤其到有多个go routine时

标准库中有sync.WaitGroup,他是一个更常用的方式实现了上述的需求

func run(ctx) {
	var wg sync.WaitGroup

	wg.Add(1)
  go func() {
		defer wg.Done()
		for {
			select {
	    case <-ctx.Done():
				fmt.Println("Break the loop")
				return;
			case <-time.After(1 * time.Second):
				fmt.Println("Hello in a loop")
			}
		}
	}()

  wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
	    case <-ctx.Done():
				fmt.Println("Break the loop")
				return;
			case <-time.After(1 * time.Second):
				fmt.Println("Ciao in a loop")
			}
		}
	}()

  wg.Wait()
	fmt.Println("Main done")
}

使用errgroup

[sync/errgroup](<https://pkg.go.dev/golang.org/x/sync/errgroup>) 包实现了更好的方式

  • errgroup的两个方法 .Go和.Wait 相比WaitGroup具有更高的可读性,更易于维护。
  • 此外,他会将错误进行传播并通过取消context,实现取消其他的go-routine,当错误发生的时候.
func run(ctx) {
	g, gCtx := errgroup.WithContext(ctx)
	g.Go(func() error {
		for {
			select {
	    case <-gCtx.Done():
				fmt.Println("Break the loop")
				return nil;
			case <-time.After(1 * time.Second):
				fmt.Println("Hello in a loop")
			}
		}
	})

	g.Go(func() error {
		for {
			select {
	    case <-gCtx.Done():
				fmt.Println("Break the loop")
				return nil;
			case <-time.After(1 * time.Second):
				fmt.Println("Ciao in a loop")
			}
		}
	}()

  err := g.Wait()
	if err != nil {
		fmt.Println("Error group: ", err)
	}
	fmt.Println("Main done")
}

终止进程

让我们从一个非常简单的“Hello in a loop”示例开始:

func main() {
	for {
		time.Sleep(1 * time.Second)
		fmt.Println("Hello in a loop")
	}
}

系统信号处理

监听 OS 信号停止进程

exit := make(chan os.Signal, 1) // 需要buffer size是1,如果阻塞,信号会被丢弃
signal.Notify(exit, os.Interrupt, syscall.SIGTERM)
  • os.Interrupt 捕捉的是正常关闭的Ctrl+C 信号SIGINT
  • syscall.**SIGTERM**是用于终止的默认的信号(它可以被修改),用于docker容器,其也被用于kubernetes
  • 了解更多的 signal in the package documentation and go by example.

打破循环

当我们可以捕捉信号的时候,我们需要一中方式来中断循环

使用非阻塞式的select channel

select可以从多个channel中消费,在每一个case语句中

可以查看以下资源以获得更好的理解

我们简单的“Hello in a loop”,现在通过term信号终止:

func main() {
	c := make(chan os.Signal, 1) // 需要buffer size是1,如果阻塞,信号会被丢弃
	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
	
	for {
		select {
    case <-c:
			fmt.Println("Break the loop")
			return;
		case <-time.After(1 * time.Second):
			fmt.Println("Hello in a loop")
		}
	}
}

我们修改了 time.Sleep(1 * time.Second) 到 time.After(1 * time.Second)

怎样使用Context

Context 是 go 中一个非常有用的接口,在所有阻塞函数中使用和传播。可以在整个程序中传播取消。

ctx context.Contex 作为每个需要直接或者间接依赖外部的方法或函数中的第一个参数是一种很好的做法。

Channle共享问题

让我们来看看context如何在更复杂的情况下提供帮助。

使用channel并行运行多个循环(反例):

func main() {
	exit := make(chan os.Signal, 1)
	signal.Notify(exit, os.Interrupt, syscall.SIGTERM)
	
  // This will not work as expected!!
	var wg sync.WaitGroup

	wg.Add(1)
  go func() {
		defer wg.Done()
		for {
			select {
	    case <-exit: // Only one go routine will get the termination signal
				fmt.Println("Break the loop: hello")
				break;
			case <-time.After(1 * time.Second):
				fmt.Println("Hello in a loop")
			}
		}
	}()

	wg.Add(1)
  go func() {
		defer wg.Done()
		for {
			select {
	    case <-exit: // Only one go routine will get the termination signal
				fmt.Println("Break the loop: ciao")
				break;
			case <-time.After(1 * time.Second):
				fmt.Println("Ciao in a loop")
			}
		}
	}()

	wg.Wait()
	fmt.Println("Main done")
}

为什么这行不通?

Go channel不以广播方式工作,只有一个 Go routine会接收单个os.Signal. 此外,不能保证哪个 goroutine 会收到它。

Context 可以帮助我们完成上述工作,让我们看看如何。

使用 Context 终止

让我们尝试通过引入来解决这个问题 [context.WithCancel](<https://pkg.go.dev/context#WithCancel>)

func main() {
  ctx, cancel := context.WithCancel(context.Background())

	go func() {
		exit := make(chan os.Signal, 1)
		signal.Notify(c, os.Interrupt, syscall.SIGTERM)
		cancel()
	}()

	var wg sync.WaitGroup

	wg.Add(1)
  go func() {
		defer wg.Done()
		for {
			select {
	    case <-ctx.Done():
				fmt.Println("Break the loop")
				break;
			case <-time.After(1 * time.Second):
				fmt.Println("Hello in a loop")
			}
		}
	}()

	wg.Add(1)
  go func() {
		defer wg.Done()
		for {
			select {
	    case <-ctx.Done():
				fmt.Println("Break the loop")
				break;
			case <-time.After(1 * time.Second):
				fmt.Println("Ciao in a loop")
			}
		}
	}()

	wg.Wait()
	fmt.Println("Main done")
}

本质上cancel()是广播给所有go routine.Done().

当调用返回的取消函数或父上下文的 Done 通道关闭时,返回的上下文的 Done 通道关闭,以先发生者为准。

公共库

HTTP服务器

在非正常关闭期间,进行中的 HTTP 请求可能面临以下问题:

  • 客户端永远不会得到回应,超时。
  • 进程在执行中,但中途中断,如果事务使用不当会造成资源浪费或数据不一致
  • 与外部依赖项的连接被另一个 go routine关闭,因此请求无法进一步进行。

***让HTTP 服务器正常关闭非常重要。*在云原生环境中,服务/Pod 在一天内多次关闭,用于自动缩放、应用配置或部署新版本的服务。因此,中断或超时请求的影响在服务的 SLA 中可能很重要。

go 提供了一种优雅地关闭 HTTP 服务器的方法。

func main() {

	ctx, cancel := context.WithCancel(context.Background())

	go func() {
		c := make(chan os.Signal, 1) // we need to reserve to buffer size 1, so the notifier are not blocked
		signal.Notify(c, os.Interrupt, syscall.SIGTERM)

		<-c
		cancel()
	}()

	db, err := repo.SetupPostgresDB(ctx, getConfig("DB_DSN", "root@tcp(127.0.0.1:3306)/service"))
	if err != nil {
		panic(err)
	}

	httpServer := &http.Server{
		Addr:    ":8000",
	}

	g, gCtx := errgroup.WithContext(ctx)
	g.Go(func() error {
		return httpServer.ListenAndServe()
	})
	g.Go(func() error {
		<-gCtx.Done()
		return httpServer.Shutdown(context.Background())
	})

	if err := g.Wait(); err != nil {
		fmt.Printf("exit reason: %s \\n", err)
	}
}

我们使用了两个 go routine:

  • httpServer.ListenAndServe() 运行
  • 等待<-gCtx.Done()然后调用**httpServer.Shutdown(context.Background())**

阅读源码可以明白他是如何工作的

Shutdown 优雅地关闭服务器,而不会中断任何活动的连接

关闭的工作原理是首先关闭所有打开的侦听器,然后关闭所有空闲连接,然后无限期地等待连接返回空闲状态,然后关闭。

如果提供的context在关闭完成之前到期,则 Shutdown 返回context的错误,否则返回关闭服务器的底层侦听器返回的任何错误。

这个例子中我们提供的是没有任何过期时间的**context.Background()**

HTTP 客户端

Go 标准库提供了一种在发出 HTTP 请求时传递上下文的方法:NewRequestWithContext

让我们看看如何重构以下代码以使用它:

resp, err := netClient.Post(uri, "application/json; charset=utf-8",
					bytes.NewBuffer(payload))
...

等效于传递 ctx:

req, err := http.NewRequestWithContext(ctx, "POST", uri, bytes.NewBuffer(payload))
if err != nil {
			return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")

resp, err := netClient.Do(req)
...

Channel耗尽

当你的go routine向channel生产或者消费时候,必须特别注意确保在chanel关闭之后,没有内容留在channel中。 close在通道上使用 go方法。这是有关关闭频道的精彩概述,以及有关该主题的更高级文章

关于关闭channel要记住两件事:

  • 写入关闭通道会导致恐慌
  • 读取频道时,您可以使用value, ok <- ch. 从关闭通道读取将返回所有缓冲的项目。一旦缓冲项被“耗尽”,通道将返回零value并且ok为false。注意:虽然频道仍然有项目,但ok将是true。
  • 或者你可以range在频道上做一个for value := range ch {。在这种情况下,当通道上没有更多项目并且通道关闭时,for 循环将停止。这比上面的方法漂亮得多,但并不总是可行的。

如果您有一个worker正在写入 channel,请在完成后关闭 channel:

go func() {
    defer close(ch) // close after write is no longer possible
    for {
			select { 
				case <-ctx.Done():
					return
				...
        ch <- value // write to the channel only happens inside the loop
    }
}()

如果您有多个worker写入同一个频道,请在等待所有worker完成后关闭频道:

g, gCtx := errgroup.WithContext(ctx)
ch = make(...) // channel will be written from multiple workers 
for w := range workers { // create n number of workers
  g.Go(func() error {
		return w.Run(ctx, ch) // workers will publish 
	})
}
g.Wait() // we need to wait for all workers to stop
close(ch) // and then close the channel

如果您正在从channel读取数据,请仅在channel没有数据时退出。本质上,生产者有责任通过关闭channel来阻止消费者:

for v := range ch {

}

// or
for {
   select {
      case v, ok <- ch:
         if !ok { // nothing left to read
           return;
         }
				 foo(v) // process `v` normally
      case ...:
					...
   }
}

用 ctx 阻塞

  • 你调用一个方法
  • 你传递给它一个上下文
  • 方法块
  • 它在发生错误或上下文被取消/超时时返回。
// calling:
err := srv.Run(ctx, ...)

// implementation

func (srv *Service) Run(ctx context.Context, ...) {
	...
	...

	for {
		...
    select {
			case <- ctx.Done()
				return ctx.Err() // Depending on our business logic, 
												 //   we may or may not want to return a ctx error:
												 //   <https://pkg.go.dev/context#pkg-variables>
		 }
	}

Setup/Shutdown

在某些情况下,使用 ctx 代码进行阻塞并不是最好的方法。当我们想要更好地控制何时.Shutdown发生时,就是这种情况。这种方法有点复杂,而且更危险的是可能忘记调用.Shutdown

用例

下面的代码演示了为什么这种模式可能有用。我们希望确保 db Shutdown 仅在 Service 不再运行后发生,因为 Service 依赖于运行的数据库才能工作。

通过调用db.Shutdown()defer,我们确保它在g.Wait返回后运行:

// calling:
func () {
   err := db.Setup() // will not block
   defer db.Shutdown()

   svc := Service{
     DB: db
   }

   g.Run(...
     svc.Run(ctx, ...)
   )
   g.Wait()
}

实现示例

type Database struct {
  ...
	cancel func() 
  wait func() err 
}

func (db *Database) Setup() {
	// ...
	// ...

  ctx, cancel := context.WithCancel(context.Background())
	g, gCtx := errgroup.WithContext(ctx)

  db.cancel = cancel
  db.wait = g.Wait

	for {
		...
    select {
			case <- ctx.Done()
				return ctx.Err() // Depending on our business logic, 
												 //   we may or may not want to return a ctx error:
												 //   <https://pkg.go.dev/context#pkg-variables>
		 }
	}
}

func (db *Database) Shutdown() error {
	db.cancel()
	return db.wait()
}


原文icon-default.png?t=LA92https://www.notion.so/golang-d0f665beb2cd45bbaaf0268404f3fc9a#6f1d5db8a9b647b0a19b9b5deffe2c84

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值