源码地址:

https://github.com/zhenbianshu/gotorch

特色功能

  • cron+,秒级定时,使任务执行更加灵活;

  • 任务列表文件路径可以自定义,建议使用版本控制系统;

  • 内置日志和监控系统,方便各位同学任意扩展;

  • 平滑重加载配置文件,一旦配置文件有变动,在不影响正在执行的任务的前提下,平滑加载;

  • IP、最大执行数、任务类型配置、支持更灵活的任务配置;

技术要点:

cron+
在实现类型cron的功能之前,我简单地看了一下cron的源码,源码在https://busybox.net/downloads/可以下载,解压后文件在miscutils>crond.c。

cron的实现设计很巧妙,大概如下:

数据结构:

  • cron拥有一个全局结构体global,保存着各个用户的任务列表;

  • 每一个任务列表是一个结构体CronFile,保存着用户名和任务链表等;

  • 每一个任务CronLine有shell命令、执行pid、执行时间数组cl_Time等属性;

  • 执行时间数组的最大长度根据”分时日月周”的最大确定,将可执行时间点的值置为true,例如每天的3点执行则cl_Hrs[3]=true;

执行方式:

  • cron是一个while(true)式的长循环,每次sleep到一下分钟的开始;

  • cron在每分钟的开始会依次编码检查用户cron配置文件,将更新后的配置文件解析成任务存入全局结构体,同时它也定期检查配置文件是否被修改;

  • 然后cron会将当前时间解析为第n分/时/日/月/周,并判断cal_Time[n]全为true则执行任务。

  • 执行任务时将pid写入防止重复执行;

  • 后续cron还会进行一些异常检测和错误处理等操作。

明白了 cron 的执行方式后,感觉每个时间单位都遍历任务进行判断于性能有损耗,而且我实现的是秒级执行,遍历判断的性能损耗更大,于是考虑优化成:

给每个任务设置一个 next_time 的时间戳,在一次执行后更新此时间戳,每个时间单位只需要判断 task.next_time == current_time。

后来由于 “秒分时日月周” 的日期格式进位不规则,代码太复杂,实现出来效率也不比原来好,终于放弃了这种想法。。采用了跟 cron 一样的执行思路。

此外,我添加了三种限制任务执行的方式:

  • IP:在服务启动时获取本地内网 IP,执行前校验是否在任务的 IP 列表中;

  • 任务类型:任务为 daemon 的,当任务没有正在执行时则中断判断直接启动;

  • 最大执行数:在每个任务上设置一个执行中任务的 pid 构成的 slice,每次执行前校验当前执行数。

而任务启动方式,则直接使用 goroutine 配合 exec 包,每次执行任务都启动一个新的 goroutine,保存 pid,同时进行错误处理。由于服务可能会在一秒内多次扫描任务,我给每个任务添加了一个进程上次执行时间戳的属性,待下次执行时对比,防止任务在一秒内多次扫描执行了多次。

守护进程
由于 Go 程序在启动时 runtime 可能会创建多个线程(用于内存管理,垃圾回收,goroutine管理等),而 fork 与多线程环境并不能和谐共存,所以 Go 中没有 Unix 系统中的 fork 方法;于是启动守护进程我采用 exec 之后立即执行,即 fork and exec 的方式,而 Go 的 exec 包则支持这种方式。

在进程最开始时获取并判断进程 ppid 是否为1 (守护进程的父进程退出,进程会被“过继”给 init 进程,其进程号为1),在父进程的进程号不为1时,使用原进程的所有参数 fork and exec 一个跟自己相同的进程,关闭新进程与终端的联系,并退出原进程。

  1. filePath, _ := filepath.Abs(os.Args[0]) // 获取服务的命令路径
  2. cmd := exec.Command(filePath, os.Args[1:]...) // 使用自身的命令路径、参数创建一个新的命令
  3. cmd.Stdin = nil
  4. cmd.Stdout = nil
  5. cmd.Stderr = nil // 关闭进程标准输入、标准输出、错误输出
  6. cmd.Start() // 新进程执行
  7. return // 父进程退出

信号处理

将进程制作为守护进程之后,进程与外界的通信就只好依靠信号了,Go 的 signal 包搭配 goroutine 可以方便地监听、处理信号。同时我们使用 syscall 包内的 Kill 方法来向进程发送信号。

我们监听 Kill 默认发送的信号 SIGTERM,用来处理服务退出前的清理工作,另外我还使用了用户自定义信号 SIGUSR2 用来作为终端通知服务重启的消息。

一个信号从监听到捕捉再到处理的完整流程如下:

  • 首先我们使用创建一个类型为 os.Sygnal 的无缓冲channel,来存放信号。

  • 使用 signal.Notify() 函数注册要监听的信号,传入刚创建的 channel,在捕捉到信号时接收信号。

  • 创建一个 goroutine,在 channel 中没有信号时 signal := <-channel 会阻塞。

  • Go 程序一旦捕捉到正在监听的信号,就会把信号通过 channel 传递过来,此时 goroutine 便不会继续阻塞。

  • 通过后面的代码处理对应的信号。

对应的代码如下:

  1. c := make(chan os.Signal)
  2. signal.Notify(c, syscall.SIGTERM, syscall.SIGUSR2)
  3. // 开启一个goroutine异步处理信号
  4. go func() {
  5. s := <-c
  6. if s == syscall.SIGTERM {
  7. task.End()
  8. logger.Debug("bootstrap", "action: end", "pid "+strconv.Itoa(os.Getpid()), "signal "+fmt.Sprintf("%d", s))
  9. os.Exit(0)
  10. } else if s == syscall.SIGUSR2 {
  11. task.End()
  12. bootStrap(true)
  13. }
  14. }()

golang 多机定时任务管理系统 - 图1