最近在学习 Go 语言,遵循着 “学一门语言最好的方式是使用它” 的理念,想着用 Go 来实现些什么,刚好工作中一直有一个比较让我烦恼的问题,于是用 Go 解决一下,即使不在生产环境使用,也可以作为 Go 语言学习的一种方式。
先介绍下问题:
组内有十来台机器,上面用 cron 分别定时执行着一些脚本和 shell
命令,一开始任务少的时候,大家都记得哪台机器执行着什么,随着时间推移,人员几经变动,任务也越来越多,再也没人能记得清哪些任务在哪些机器上执行了,排查和解决后台脚本的问题也越来越麻烦。
解决这个问题也不是没有办法:
- 维护一个 wiki,一旦任务有变动就更新 wiki,但一旦忘记更新 wiki,任务就会变成孤儿,什么时候出了问题更不好查。
- 布置一台机器,定时拉取各机器的 cron 配置文件,进行对比统计,再将结果汇总展示,但命令的写法各式各样,对比命令也是个没头脑的事。
- 使用开源分布式任务调度任务,比较重型,而且一般要布置数据库、后台,比较麻烦。
除此之外,任务的修改也非常不方便,如果想给在 crontab 里修改某一项任务,还需要找运维操作。虽然解决这个问题也有办法,使用 crontab cronfile.txt 直接让 crontab 加载文件,但引入新的问题:任务文件加载的实时性不好控制。
为了解决以上问题,我结合 cron 和任务管理,每天下班后花一点时间,实现一个小功能,最后完成了 gotorch 的可用版。看着 GitHub 的 commit 统计,还挺有成就感的~
这里放上 GitHub 链接地址: GitHub-zhenbianshu-gotorch ,欢迎 star/fork/issue。
介绍一下特色功能:
- cron+,秒级定时,使任务执行更加灵活;
- 任务列表文件路径可以自定义,建议使用版本控制系统;
- 内置日志和监控系统,方便各位同学任意扩展;
-平滑重加载配置文件,一旦配置文件有变动,在不影响正在执行的任务的前提下,平滑加载; - IP、最大执行数、任务类型配置,支持更灵活的任务配置;
下面说一下功能实现的技术要点:
cron+在实现类似 cron 的功能之前,我简单地看了一下 cron 的源码,源码在 https://busybox.net/downloads/ 可以下载,解压后文件在miscutils > crond.c。
cron 的实现设计得很巧妙的,大概如下:
数据结构:
-
cron 拥有一个全局结构体 global ,保存着各个用户的任务列表;
-
每一个任务列表是一个结构体 CronFile, 保存着用户名和任务链表等;
-
每一个任务 CronLine 有 shell 命令、执行 pid、执行时间数组 cl_Time 等属性;
- 执行时间数组的最大长度根据 “分时日月周” 的最大值确定,将可执行时间点的值置为 true,例如 在每天的 3 点执行则
cl_Hrs3=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,同时进行错误处理。由于服务可能会在一秒内多次扫描任务,我给每个任务添加了一个进程上次执行时间戳的属性,待下次执行时对比,防止任务在一秒内多次扫描执行了多次。
守护进程本服务是做成了一个类似 nginx 的服务,我将进程的 pid 保存在一个临时文件中,对进程操作时通过命令行给进程发送信号,只需要注意下异常情况下及时清理 pid 文件就好了。
这里说一下 Go 守护进程的创建方式:
由于 Go 程序在启动时 runtime 可能会创建多个线程(用于内存管理,垃圾回收,goroutine管理等),而 fork 与多线程环境并不能和谐共存,所以 Go 中没有 Unix 系统中的 fork 方法;于是启动守护进程我采用 exec 之后立即执行,即 fork and exec 的方式,而 Go 的 exec 包则支持这种方式。
在进程最开始时获取并判断进程 ppid 是否为1 (守护进程的父进程退出,进程会被“过继”给 init 进程,其进程号为1),在父进程的进程号不为1时,使用原进程的所有参数 fork and exec 一个跟自己相同的进程,关闭新进程与终端的联系,并退出原进程。
filePath, _ := filepath.Abs(os.Args[0]) // 获取服务的命令路径
cmd := exec.Command(filePath, os.Args[1:]...) // 使用自身的命令路径、参数创建一个新的命令
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil // 关闭进程标准输入、标准输出、错误输出
cmd.Start() // 新进程执行
return // 父进程退出
信号处理
将进程制作为守护进程之后,进程与外界的通信就只好依靠信号了,Go 的 signal 包搭配 goroutine 可以方便地监听、处理信号。同时我们使用 syscall 包内的 Kill 方法来向进程发送信号。
我们监听 Kill 默认发送的信号 SIGTERM,用来处理服务退出前的清理工作,另外我还使用了用户自定义信号 SIGUSR2 用来作为终端通知服务重启的消息。
一个信号从监听到捕捉再到处理的完整流程如下:
- 首先我们使用创建一个类型为 os.Sygnal 的无缓冲channel,来存放信号。
- 使用 signal.Notify() 函数注册要监听的信号,传入刚创建的 channel,在捕捉到信号时接收信号。
- 创建一个 goroutine,在 channel 中没有信号时 signal := <-channel 会阻塞。
- Go 程序一旦捕捉到正在监听的信号,就会把信号通过 channel 传递过来,此时 goroutine 便不会继续阻塞。
- 通过后面的代码处理对应的信号。
对应的代码如下:
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGTERM, syscall.SIGUSR2)
// 开启一个goroutine异步处理信号
go func() {
s := <-c
if s == syscall.SIGTERM {
task.End()
logger.Debug("bootstrap", "action: end", "pid "+strconv.Itoa(os.Getpid()), "signal "+fmt.Sprintf("%d", s))
os.Exit(0)
} else if s == syscall.SIGUSR2 {
task.End()
bootStrap(true)
}
}()
小结
gotorch 的开发共花了三个月,每天半小时左右,1~3 个 commits,经历了三次大的重构,特别是在代码格式上改得比较频繁。 不过使用 Go 开发确实是挺舒心的,Go 的代码很简洁, gofmt 用着非常方便。另外 Go 的学习曲线也挺平滑,熟悉各个常用标准包后就能进行简单的开发了。 简单易学、高效快捷,难怪 Go 火热得这么快了。
关于本文有什么问题可以在下面留言交流,如果您觉得本文对您有帮助,可以点击下面的 推荐 支持一下我,博客一直在更新,欢迎 关注 。
参考:
热门评论
和楼主一样的经历:
1、为了解决多台机器上运行的PHP定时任务
2、业余时间搞出来的定时任务管理系统
3、同样使用go语言开发
4、同样适用cron包
5、已经部署在线上使用
github地址:https://github.com/george518/PPGo_Job
多交流哈