Try   HackMD

フォアグラウンドジョブ/バックグラウンドジョブの実現方法 - シェルもどきをgoで自作する#20

おさらい

シェルもどき「oreshell」を自作している。
前回は、シグナルついて調べた。
今回はフォアグラウンドジョブ/バックグラウンドジョブの実現について。

ジョブ(プロセスグループ)のフォアグラウンド/バックグラウンド

現状のoreshellはフォアグラウンドジョブしか実行できない。
コマンド末尾に「&」をつけて、バックグラウンドジョブ実行できるようにしたい。

oreshellにバックグラウンドジョブ実行を組み込んだコードを説明するには、oreshellのコードが大きくなりすぎたため難しい。よってoreshellの機能縮小版を用意してそれを使って説明する。

oreshellの機能縮小版 pingstarter

oreshellの機能縮小版として、子プロセスとしてpingコマンドしか実行できないツール、pingstarterを作る。

以下はその仕様。

  • プロンプトから「f」または「b」しか入力できない。それ以外の入力は無視する。
  • 「f」のときは「ping -c 3 yahoo.co.jp」をフォアグラウンドジョブとして実行する。
  • 「b」のときは「ping -c 3 yahoo.co.jp」をバックグラウンドジョブとして実行する。(「ping -c 3 yahoo.co.jp &」)
  • バックグラウンドジョブ実行が完了したら「terminated.」という文字列を、次の入力後に標準出力する。

pingstarterの実装

ベースとしたコードは第一回のこちら

当初は

procAttr.Sys = &syscall.SysProcAttr{
  Setpgid: true, 
  Foreground: foreground, // true or false
  } 

とすれば変更作業終わりだろうと考えていたが甘かった。

かなり試行錯誤した結果、何とか期待通りに動くようになったコードは以下の通り。

func main() {
    // シグナルSIGTTIN、TTOUは無視する
    signal.Ignore(syscall.SIGTTIN, syscall.SIGTTOU) // ★F

    // 自分自身のプロセスグループIDを取得しておく
    pgrpid := tcgetpgrp() // ★F
    
    reader := bufio.NewReader(os.Stdin)

    // バックグラウンド実行終了後の終了メッセージを貯めるキュー
    // 10はとりあえずの値
    bgTerminateMsgs := make(chan string, 10) //★B

    for {
        fmt.Printf("(pingstarter) > ")

        line, _, err := reader.ReadLine()

        if err != nil {
            log.Fatalf("ReadLine %v", err)
        }
        command := string(line)

        // "f"(フォアグラウンド)か"b"(バックグラウンド)を入力したらpingを実行する。
        // それ以外の場合は何もしない
        if command == "f" || command == "b" { // ★F,B
            foreground := (command == "f") // ★F,B
            var procAttr os.ProcAttr
            procAttr.Sys = &syscall.SysProcAttr{Setpgid: true, Foreground: foreground}  // ★F, B
            procAttr.Files = []*os.File{nil, os.Stdout, os.Stderr}
            process, err := os.StartProcess("/usr/bin/ping", []string{"/usr/bin/ping", "-c", "3", "yahoo.co.jp"}, &procAttr)
            if err != nil {
                log.Fatalf("StartProcess %v", err)
            }

            // 起動したpingの実行終了を待つ関数を定義
            waitCommandTerminated := func() { // ★F,B
                _, err = process.Wait()
                if err != nil {
                    log.Fatalf("process.Wait %v", err)
                }

                if foreground {
                    tcsetpgrp(pgrpid) // ★F
                } else {
                    bgTerminateMsgs <- "terminated."  // ★B
                }
            }

            if foreground {
                // ブロック
                waitCommandTerminated() // ★F
            } else {
                // ノンブロック
                // 「起動したpingの実行終了を待つ」関数をゴルーチンで実行
                go waitCommandTerminated() // ★B
            }
        }

    L: // ★B
        for {
            select {
            // キューにメッセージが貯まっていれば、それを一つ取り出して
            case v := <-bgTerminateMsgs:
                fmt.Println(v)
            // キューにメッセージがなければ
            default:
                break L
            }
        }
    }
}

// 現在のプロセスの制御端末を取得
func devTty() *os.File { // ★F
    tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
    if err != nil {
        log.Fatalf("Couldn't open /dev/tty %s", err)
    }
    return tty
}
  
// 指定した制御端末のフォアグラウンドプロセスグループIDを取得
func tcgetpgrp() int { // ★F
    tty := devTty()
    defer tty.Close()
  
    pgrpid, _ := unix.IoctlGetInt(int(tty.Fd()), unix.TIOCGPGRP)
    return pgrpid
}
  
// 指定したプロセスグループを指定した制御端末のフォアグラウンドプロセスグループにする。
func tcsetpgrp(pgrpid int) { // ★F
    tty := devTty()
    defer tty.Close()
  
    unix.IoctlSetPointerInt(int(tty.Fd()), unix.TIOCSPGRP, pgrpid)
}

★F、★Bはフォアグラウンドジョブ(プロセスグループ)実行/バックグラウンドジョブ(プロセスグループ)実行に関連する変更点である。
以下に変更内容の説明を示す。

フォアグラウンドジョブ実行部分の変更内容(★F)

先にも書いたが、フォアグラウンドジョブ(プロセスグループ)/バックグラウンドジョブ(プロセスグループ)の切り替えは以下だけで済むと考えいた。

procAttr.Sys = &syscall.SysProcAttr{
  Setpgid: true, 
  Foreground: foreground, // true or false
  } 

しかし、この変更だけではフォアグラウンドジョブ実行が期待した動作にならない。
泣きながら3日間ほどぐぐったところ、goのサンプルコード集にunix上でのプロセス生成の例が見つかった。
ここを見ると、goのプログラムの中で子プロセスをフォアグラウンドプロセスグループで起動する例が書いてある。
ポイントは以下のとおり。

  • 起動したプロセスが実行完了した後にioctlのTIOCSPGRP( tcsetpgrp() と同じ)を呼ぶ。
  • シグナルSGITTIN, SIGTTOUを無視する。

gnuのドキュメントのImplementing a Job Control Shell の Initializing the Shellにも同じようなことが書いてある。

bashでもSIGTTIN,SIGTTOUが飛んで来たらそれを無視するらしい。

Commands run as a result of command substitution ignore the keyboard-generated job control signals SIGTTINSIGTTOU, and SIGTSTP.
ジョブ制御が有効な場合 (ジョブ制御を参照) 、BashはSIGTTIN、SIGTTOU、SIGTSTPを無視します。(みらい翻訳)

tcsetpgrp()ってなに?

ubuntuのmanpageによると

#include <unistd.h>

int tcsetpgrp(int fd, pid_t pgrp);

関数tcsetpgrp()は、プロセスグループIDがpgrpのプロセスグループをfdに対応する端末のフォアグラウンドプロセスグループにする。

とある。
第2引数のpgrp、つまりpingstarterのプロセスグループIDはどうやって取得すればよいか?とぐぐってみると

#include <unistd.h>

pid_t tcgetpgrp(int fd);

を使えばよいらしい。

SIGTTIN、SIGTTOUってなに?

wikipediaではSIGTTIN、SIGTTOUについて以下の通り。

シグナル名 : SIGTTIN
説明 : バックグラウンドプロセスが端末から読もうとした
デフォルト動作 : 一時中断
解説 : バックグラウンドのプロセスグループがユーザー入力待ちとなって停止。シェルの機能を使ってフォアグラウンドにすることで入力が可能。

シグナル名 : SIGTTOU
説明 : バックグラウンドプロセスが端末に書き込もうとした
デフォルト動作 : 一時中断
解説 : バックグラウンドのプロセスグループが端末への表示待ちとなって停止。シェルの機能を使ってフォアグラウンドにすることで表示可能。

ここによると、tcsetpgrp()呼ぶとシグナルSIGTTOUを生成するとある。
このシグナルSIGTTOUを無視しないと、pingstarterが動作を中断してしまう。
どういう理由でtcsetpgrp()がシグナルSIGTTOUを発生するのか理由はさっぱりわからない。
が、前述の通りbashもジョブ制御時にはこの2つのシグナルを無視するらしいので真似することにする。(SIGTTINは無視しなくても今のところ動いているが、無視することにする。)

上記をまとめた図を以下に示す。

oreshell_filedescriptor-ページ25.drawio (1)

バックグラウンドジョブ実行部分の変更内容(★B)

バックグラウンド実行はすんなり動いた。
以下は変更内容の概要。

  • メインスレッドは、起動したpingの実行終了を待たずに先に進む。
  • サブスレッド(ゴルーチン)は、起動したpingの実行終了を待ち、終了したら終了メッセージをキューに入れる。
  • メインスレッドは、次の入力(「f」、「b」、それ以外)終了時にキューに貯まったメッセージを標準出力する。

第1回からこれまでのoreshellでは、コマンドの実行をフォアグラウンド実行していたつもりがバックグラウンド実行していたのだろうか?(ただし、コマンドが終わるまで待っていた。)

実行例

$ ./pingstarter
(pingstarter) > f
PING yahoo.co.jp (182.22.31.124) 56(84) bytes of data.
64 bytes from 182.22.31.124 (182.22.31.124): icmp_seq=1 ttl=56 time=11.0 ms
64 bytes from 182.22.31.124 (182.22.31.124): icmp_seq=2 ttl=56 time=10.6 ms
64 bytes from 182.22.31.124: icmp_seq=3 ttl=56 time=10.9 ms

--- yahoo.co.jp ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2045ms
rtt min/avg/max/mdev = 10.641/10.840/11.001/0.149 ms
(pingstarter) > b
(pingstarter) > PING yahoo.co.jp (182.22.31.124) 56(84) bytes of data.
64 bytes from 182.22.31.124 (182.22.31.124): icmp_seq=1 ttl=56 time=10.6 ms
64 bytes from 182.22.31.124 (182.22.31.124): icmp_seq=2 ttl=56 time=10.6 ms
64 bytes from 182.22.31.124: icmp_seq=3 ttl=56 time=10.8 ms

--- yahoo.co.jp ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2033ms
rtt min/avg/max/mdev = 10.555/10.668/10.833/0.119 ms

terminated.
(pingstarter) >

次回

oreshellにフォアグラウンドジョブ/バックグラウンドジョブ実行を組み込む。あとできればjobコマンドも。