Published on

占用终端的程序

Authors
  • Name

终端中运行的程序难免有些需要运行很久,而这些程序通常很不方便地占用了它所在的终端:让他自己跑的话就不能继续用这个终端,需要开一个新的;如果它在运行时持续输出信息,就算用了 tmux/zellij/byobu 等终端复用器,其的输出信息也不易保存或检查;如果一个任务不仅跑得慢还要阻塞后续工作流,朴素的人工轮询(🌚)方法也会拉低自己的整体效率。这篇文章分享一些能够减轻这种不便的异端正统技巧。


TL;DR

  1. 用 systemd 接管在后台运行的程序,并用 journalctl 查看输出
  2. 在长时间运行的程序结束时发送桌面通知

目录

将后台程序的输出记录到系统日志

适用性

本节介绍的方法仅适用于以 systemd[1] 做 init 进程的 Linux 系统(大部分都是)。

在后台运行

让本在前台运行的程序在后台运行有很多种方法,常见的两种方法:1. 在终端中的命令最后加上 & 后再 disown;2. 给在终端前台运行的程序按 ^Z 后向其发送 SIGCONT 再 disown。这两种做法可以让程序即使在终端会话关闭后仍能运行,但是程序的输出只会被输出到同一个终端,这个终端被关闭后就不能再去查看这个程序的输出了(除非强行使用一些不那么优雅的 hack[2])。这两种朴素方法大概长这样:

$ sleep 999 &
$ disown
$ ps ax | grep sleep
   8849 pts/3    S      0:00 sleep 999
   9095 pts/3    S+     0:00 grep --color=auto sleep
$ kill 8849
$
$ # Or ..
$ sleep 999
^Zfish: Job 1, 'sleep 999' has stopped
$ kill -CONT %1
$ disown
$ px ax | grep sleep
   9258 pts/5    S      0:00 sleep 999
   9416 pts/5    S+     0:00 grep --color=auto sleep

将程序输出接入到系统日志

如今 systemd[1:1] 已经是许多 Linux 发行版的默认 init 程序。systemd 提供了将任意程序的 stdout 和 stderr 接入到系统日志的工具 systemd-cat。利用这个工具,我们就可以从系统日志中查看程序的输出:

$ systemd-cat echo Hello world!; journalctl -e | tail -n1
Apr 16 19:51:04 john echo[12433]: Hello world!

说明

以上的命令首先用 systemd-catecho Hello world! 的输出接入到系统日志中,然后用 journalctl -e | tail -n1 查看日志最末端(即最新)的一条信息。

如果将此处的 echo 换成一个运行时间很长又有持续输出的程序(namely,炼丹), systemd-cat 就会将程序输出持续记录进系统日志,这样一来,结合朴素方法中的 &disown即使所在机器重启,也可以随时用 journalctl 查看带有时间戳的运行日志。比如一个程序或脚本要持续运行 24 小时,要将其输出记录进系统日志:

$ cat >runs-for-24hours <<EOF
#!/usr/bin/env bash
for ((i = 86400; i > 0; --i)); do
  echo This process will terminate after $i seconds ...
  sleep 1
done
EOF
$ systemd-cat bash runs-for-24hours &; disown

此后就可以随时使用 journalctl -f 跟随其最新的输出(用 ^C 停止跟随):

$ journalctl -f
Apr 16 20:09:21 john bash[15240]: This process will terminate after 86387 seconds ...
Apr 16 20:09:22 john bash[15240]: This process will terminate after 86386 seconds ...
Apr 16 20:09:23 john bash[15240]: This process will terminate after 86385 seconds ...
Apr 16 20:09:24 john bash[15240]: This process will terminate after 86384 seconds ...
Apr 16 20:09:25 john bash[15240]: This process will terminate after 86383 seconds ...
Apr 16 20:09:26 john bash[15240]: This process will terminate after 86382 seconds ...
Apr 16 20:09:27 john bash[15240]: This process will terminate after 86381 seconds ...
Apr 16 20:09:28 john bash[15240]: This process will terminate after 86380 seconds ...
Apr 16 20:09:29 john bash[15240]: This process will terminate after 86379 seconds ...
Apr 16 20:09:30 john bash[15240]: This process will terminate after 86378 seconds ...
Apr 16 20:09:31 john bash[15240]: This process will terminate after 86377 seconds ...
^C

要中止运行,杀掉上面日志中记录的的进程号即可:

$ kill 15240
sending signal 15 to pid 15240

用 systemd unit 分离日志信息

以上方法已经解决了后台运行程序难以记录和查看输出这一大痛点,但是有一个小问题是直接使用 journalctl -f 跟随的日志实际上是所有用户进程的日志(或如果你的用户恰好是系统管理员,这样跟随的就是全部系统进程的日志),也就是说 journalctl -f 的输出中包含了其他我们不那么关心的程序的日志信息,这些信息对我们来说是噪音。

journalctl 提供了一个参数 -u|--unit,能够指定查询某个 systemd unit 的日志,例如,查询本机的 sshd 日志:

$ journalctl --unit=sshd  # 或 journalctl -usshd
...

Note

如果以上命令提示无法查看别的用户和系统级的日志,说明你不是系统管理员,但这完全不影响以下将要描述的功能。

所以我们可以通过指定 systemd unit 的名称来过滤掉那些我们不关心的日志条目。那么现在问题就转化为如何让终端里启动的后台程序存在于一个独立的 systemd unit。 systemd[1:2] 恰好[3]提供了将程序运行为临时服务的工具systemd-run。要让一个终端程序运行为一个临时 systemd unit(以上面的 runs-for-24hours 脚本为例):

$ systemd-run --user --scope --unit=long-running-process bash runs-for-24hours
Running scope as unit: long-running-process.scope
^C

注意

  • 要让被执行的程序以当前 shell 作为父进程(就像它直接被运行的那样),必须指定 --scope flag[4]
  • 如果不指定 --scope flag,生成的临时 systemd unit 会在一个分离的环境(systemd 所管理的环境)中运行,这样的被执行程序与当前 shell 的环境变量不同。要让被执行程序仅仅与当前 shell 具有相同的所在目录而不关心是否具有相同环境变量,可以给 systemd-run 添加 --same-dir flag(已知 systemd 237 版本不支持这个 flag)。 [4:1]

这样一个临时 systemd scope unit 就在前台开始运行了,要在日志中跟随它的最新输出:

$ journalctl --user --unit=long-running-process.scope --follow

Note

把程序包进一个 systemd unit 的另一个好处是可以利用 systemd 管理的 cgroupv2 进程树来干净地杀掉这个程序及其所有的子进程:

$ # 查看 cgroupv2 进程树
$ systemd-cgls
$ systemctl --user stop long-running-process.scope
$ # 进程树中不再有被杀掉的 long-running-process.scope 了
$ systemd-cgls

完成[5]

结合以上三个 trick,要让一个从终端启动的程序:

  1. 在后台运行 (&; disown
  2. 输出被记录到系统日志(systemd-cat
  3. 在日志中有属于自己的 unit(systemd-run

最终执行的命令看起来大概是这样(以上面的 runs-for-24hours 脚本为例):

$ systemd-run --user --scope --unit="long-running-process.scope" systemd-cat bash runs-for-24hours &
Running scope as unit: long-running-process.scope
$ disown
$ journalctl --user -fu long-running-process.scope  # 跟随最新输出

自动化

在终端里输入这么一大串显然并不合理。推荐的做法是把这个逻辑写进一个脚本 [sdwrap],再放到自己的 PATH 里即可直接调用:

$ sdwrap bash runs-for-24hours
Running scope as unit: long-running-process.scope

这是一个 sdwrap 的简单实现

运行结束时通知我!

适用性

本节介绍的方法以 fish[6] 为例,类似的功能(也许)也可以在 zsh 中实现。

一些程序运行时间没有像上一个例子中的一天那么长,但它们也并非立刻完成,有时我们能知道它将何时结束,但更多时候则只有一个模糊的估计。这种程序运行起来后也常常会 block 整个之后的 workflow,在等候过程中,我们虽然会去检查其他资料、看论文、或者单纯是去摸鱼,但仍然希望能尽快继续这个被 block 的 workflow。

对于这个问题,一种朴素的做法是进行轮询(🌚):在检查资料/看论文/摸鱼时把手指放好在切换窗口/虚拟工作区的快捷键上,每隔一定时间就进行一次 poll(来回切换并检查 blocking 的任务是否已完成)。这样不仅降低了检查资料/看论文/摸鱼的效率,也容易忘记做下一次 poll 导致整个流程被自己 block。

我所使用的 shell 是 fish[6:1],它提供了「事件」的概念让用户可以根据一定的事件进行 scripting。在所提供的事件中包含一项为 fish_postexec,其会在上一个命令结束运行时触发,触发后的事件中包含刚刚结束的命令内容以及其运行时间。通过这一事件,fish 就能够告知我们「这个 block 你 workflow 的玩意结束了快回来工作」。具体方法是用 fish 的 function 以及其 --on-event 参数:

function __notify_when_long_running_process_finishes --on-event fish_postexec
  notify-send --expire-time=1500 \
    "Get back to work!" \
    "Command '$argv[1]' has finished after '$CMD_DURATION'ms"
end

把上面的函数写进一个文件 __notify_when_long_running_process_finishes.fish 并放在 fish 的本地函数文件夹 ~/.config/fish/functions/ 下,并在 ~/.config/fish/config.fish 中添加:

if command -v notify-send >/dev/null
  and not set -q SSH_CONNECTION
  and not test (id -u) -eq 0
  and not string match --quiet --entire --regex '^/dev/tty\d*$' (tty)
  __notify_when_long_running_process_finishes
end

说明

if 后的四个判断条件依次是:

  1. notify-send 命令是可执行的
  2. 当前 shell 会话并非通过 ssh 连接启动
  3. 当前用户并非 root(因为用 root 跑图形界面是不被推荐的[7]
  4. 当前 shell 不在 tty 下

四个条件同时满足时再启用桌面通知。

重新 load fish,现在每条命令结束后,fish_postexec 事件都会被释放,上面的函数接收到事件后就会发送桌面通知: get-back-to-work-from-fish-shell

判断条件

为了让 fish 不要给每条命令都发一条通知,上面的函数里需要添加一些判断条件,如忽略一些特定命令仅在上一条命令运行时间超过某一阈值时才发送通知,等等。

这是一个监听 fish_postexec 事件的函数的简单实现


  1. https://systemd.io ↩︎ ↩︎ ↩︎

  2. https://stackoverflow.com/questions/593724/redirect-stderr-stdout-of-a-process-after-its-been-started-using-command-lin ↩︎

  3. 底裤d,真好用 ↩︎

  4. https://man.archlinux.org/man/systemd-run.1 ↩︎ ↩︎

  5. https://github.com/blurgyy/dotfiles/blob/master/dot-local/bin/sdwrap ↩︎

  6. https://github.com/fish-shell/fish-shell ↩︎ ↩︎

  7. https://wiki.archlinux.org/title/Running_GUI_applications_as_root ↩︎