造轮子必备: 什么是优雅关闭?

4,030 阅读5分钟

「这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

引子?

关闭?还特么优雅?

实际上优雅关闭还有另外一个名词, 叫“平滑退出"。如果你打算自己造轮子, 优雅关闭将是你要掌握的第一个知识点。

在生活中,如果有一个名词确实过于难以理解,我们不妨来看这个名词的反面是什么。

举个简单的例子

  • 优雅关闭: 就是使用操作系统关机功能关闭你的计算机
  • 不太优雅的关闭: 直接断电重启

可能有同学会有疑问了: 我平时电脑卡住的时候都是直接断电重启的啊,也没见有啥大问题啊?

计算机与计算机的体质不能一概而论, 如果你的计算机安装的是Linux系统,恰好这又是一台服务器的话,强制重启的话,你大概率会丢掉部分数据,如果是生产环境的话,那就准备提桶跑路吧。

为什么呢?

如果你曾经想过要做MySQL或者Redis的调优, 或多或少接触过以下参数:

  • MySQLsync_binlog参数, 用来控制持久化binlog数据的存储设备的行为
  • Redisappendfysnc参数, 用来控制Redis持久化AOF日志到数据存储设备的行为。

出现以上参数的原因是因为将数据持久化到存储设备是一个耗时相对较高的行为, Linux采取的优化措施是,当你往一个文件中数据时会暂存到系统的缓存中, 等待时机再批量持久化到存储设备中。除非进程指定使用DirectIO的方式或者调用fsync,操作系统才会主动将数据写入存储设备。

因此MySQLRedis纷纷开放了调优参数用来控制日志持久化行为,并将锅甩回给了程序员。

sync_binlog 的值一般为1, 即每次提交事务就立即将binlog数据持久化到存储设备。

优雅关闭

现在,从不太优雅关闭的例子了解到优雅关闭要做什么了:

  • 让程序完成未完成的工作(如:提交事务, 持久化日志等等)

但是,我们还需要加一个限制条件:

  • 当程序决定优雅关闭的时候,就不能再接送任何请求。

不停止处理新请求的话就永远没完没了

线程池的优雅关闭

线程池(ThreadPoolExecutor)在JDK的并发包中占据了重要的位置, 我们可以来看看如此重要的一个基础组件是如何处理优雅关闭的。

该类将是否需要优雅关闭的权限开放给程序员, 并提供了两个方法,分别是:

  • ThreadPoolExecutor.shutdown 该方法会将线程池的状态设置为SHUTDOWN, 并且不再接受新任务的提交,但会让线程池中的线程跑完所有已提交的任务
  • ThreadPoolExecutor.shutdownNow 该方法会将线程池的状态设置为STOP, 并且不再接受新任务的提交, 以及立即向线程池中的所有线程发出中断信号,对于使已提交到线程池中但还未运行的任务直接忽视掉。

优雅关闭进程

如何优雅关闭进程呢? 首先我们需要搞清楚进程什么情况下会关闭:

  • 主动关闭(对于一个对外提供服务的进程来说通常不会主动关闭)
  • 程序崩溃, 如某个业务抛出异常处理不当,导致异常抛到最外层并且没有进行处理导致程序崩溃
  • 进程收到来自操作系统的关闭信号(如按下Ctrl+C)

在企业级应用中,一个进程通常不止有业务逻辑,还有围绕着业务而开发的日志服务/MQ服务/运维服务等等, 那么当某个业务出现可能导致进程崩溃的问题时,我们就需要将进程即将关闭的消息广播给其他服务, 并调用这些服务提供的优雅关闭方法, 以上措施全部完成后再退出进程, 如日志服务的优雅关闭是确保日志落盘, MQ服务的优雅关闭是确保消息被投递出去或者被消费完等等。

如果你是直接杀进程(kill -9)的话, 也就没有必要讨论优雅关闭了

我们以Golang为例来描述如何优雅关闭进程, 首先我们需要对进程中的服务做一个抽象,以便实现生命周期管理, 每个服务提供均需要提供ServeShutdown方法。

type Service interface {
	Serve(ctx context.Context) error
	Shutdown() error
}

接下来我们定义一个ServiceGroup用来管理Service生命周期, 当任意Service运行出错或接收系统信号SIGINT(Ctrl+C触发)和SIGTREM(kill 不加参数), ServiceGroup将负责关闭关闭由此管理的Service并调用Shutdown方法。

type ServiceGroup struct {
	ctx      context.Context
	cancel   func()
	services []Service
}

func NewServiceGroup(ctx context.Context) *ServiceGroup {
	g := ServiceGroup{}
	g.ctx, g.cancel = context.WithCancel(ctx)
	return &g
}

func (s *ServiceGroup) Add(service Service) {
	s.services = append(s.services, service)
}

func (s *ServiceGroup) run(service Service) (err error) {
	defer func() {
		if  r := recover(); r != nil {
			err = r.(error)
		}
	}()
	err = service.Serve(s.ctx)
	return
}

func (s *ServiceGroup) watchDog() {
	signalChan := make(chan os.Signal, 1)
	signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
	for {
		select {
		case <- signalChan:
			// 接收到系统信号, 通知停止服务
			s.cancel()
			goto CLOSE
		case <- s.ctx.Done():
			// 上下文被取消

			goto CLOSE
		}
	}
CLOSE:
	for _, service := range s.services {
		if err := service.Shutdown(); err != nil {
			fmt.Printf("shutdown failed err: %s", err)
		}
	}
}

func (s *ServiceGroup) ServeAll() {
	var wg sync.WaitGroup
	for idx := range s.services {
		service := s.services[idx]
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := s.run(service); err != nil {
				fmt.Println("服务异常, 进入退出流程!")
				s.cancel()
			}
		}()
	}
	wg.Add(1)
	go func() {
		defer wg.Done()
		s.watchDog()
	}()
	wg.Wait()
}

接下来,我们定义一个会随机panic的业务服务以及日志服务。

type BusinessService struct {
}

func (b *BusinessService) Serve(ctx context.Context) (err error) {
	times := 0
	for {
		fmt.Printf("业务运行中 %d\n", times)
		select {
		case <- ctx.Done():
			fmt.Printf("BusinessService receive cancel signal\n")
			return
		default:
			if n := rand.Intn(256); n > 200 {
				panic(fmt.Errorf("random panic on %d", n))
			}
		}
		time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
		times++
	}
	return
}

func (b *BusinessService) Shutdown() error {
	fmt.Println("业务服务, 关闭!")
	return nil
}

type LogService struct {
	buffer []string
}

func (l *LogService) Serve(ctx context.Context) (err error) {
	for {
		select {
		case <- ctx.Done():
			return
		default:
			// 投递日志到消息队列
			time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))
			l.buffer = append(l.buffer, fmt.Sprintf("Time: %d", time.Now().Unix()))
		}
	}
}

func (l *LogService) Shutdown() (err error) {
	fmt.Printf("日志服务, 关闭! 有[%d]条日志待发送\n", len(l.buffer))
	if len(l.buffer) == 0 {
		return
	}
	for _, log := range l.buffer {
		// 发送日志或者持久化到硬盘
		fmt.Printf("Send Log [%s]\n", log)
	}
	fmt.Println("缓冲区日志清理完毕")
	return
}

运行

func main() {
	rand.Seed(time.Now().Unix())
	ctx := context.Background()
	g := NewServiceGroup(ctx)
	g.Add(&LogService{})
	g.Add(&BusinessService{})
	g.ServeAll()
}

运行输出如下所示:

image.png

以上代码还有诸多优化的地方, 读者可自行改进。如可以使用errorgroup对服务进行管理, 以及Shutdwon的时候也可传入上下文做超时管理。

总结

什么是优雅关闭?

  • 让程序完成已提交但未完成的工作
  • 不再接收新的请求