yuji

← 記事一覧

外部プロセスのお片付け

はじめに

この記事は Bad Apple!! を ASCIIアート化する時につまずいたプロセスの終了判定について書いた記事です。

こちらが制作物の紹介ページになります。よければご覧ください。

AA化のロジックは3行だけ!?

制作当初は AA(ASCII Art)化のロジックに苦戦すると予想していました。しかし蓋を開けてみると3行で済んでしまいました。

//フレームを読み込むループ
for i := range screenHeight * screenWeight {
	// ← 輝度→文字の対応づけ
	idx := int(buf[i]) * (len(ramp) - 1) / 255
	screen = append(screen, ramp[idx])
	// ← 画面の最下行に改行を打たない
	if i == screenHeight*screenWeight-1 {
		break
	}
	// ←1次元のバイト列を2次元に折り返す
	if (i+1)%screenWeight == 0 {
		screen = append(screen, '\n')
	}
}

効いているのは、輝度から文字を選ぶ idx の行、選んだ文字を積む行、行末で改行を挟む行の3つだけです。残りは境界条件でした。

コードの大半はプロセスの後始末

今回のプロジェクトを作成するにあたって ffmpeg という CLI 上で動画と音声を変換・処理できるツールを使用しました。これに下のような重めの処理を全て任せていました。

  • MP4 コンテナの解析、映像トラックの取り出し
  • 映像コーデックのデコード
  • フレームレートを揃える
  • 文字セルの縦横比に合わせて縦を半分に潰す
  • アスペクト比を保った端末サイズへの縮小と、余白の黒染め
  • エンコードせずに輝度のバイト列のまま吐く
  • 音声のデコードと、OS のオーディオデバイスへの再生

映像関連の全てですね。

任せたのは計算だけではありませんでした。余白を黒で埋めてくれるおかげで1フレームは必ず横×縦バイトになり、固定長で読めます。フレームレートが揃っているおかげで、読んだ枚数だけで今が何秒目かも決まります。3行で済んだのは、こうした前提を全部 ffmpeg が用意してくれていたからでした。

そして音声を ffplay に任せた分だけ、後で片付ける相手が1つ増えました。よって僕の仕事は子プロセスの呼び出しと終了判定、リソースの後始末でした。

終わり方は3つあった

再生自体は早い段階で動きました。困ったのは止めたときです。

Ctrl+C で中断すると、そのあとの端末でカーソルが消えたままになります。プロンプトは出ているのに入力位置が見えない。reset を打つまで戻りません。ついでに ffplay の音だけが鳴り続けることもありました。

カーソルを消しているのは僕です。そのままだと描画のたびにカーソルが画面上を走り回って邪魔なので、再生前に消しています。

//カーソルを非表示
fmt.Print("\033[?25l")

これはファイルを開くのとは違います。ファイルディスクリプタならプロセスが死んだ時点で OS が回収してくれますが、エスケープシーケンスで変えたのは端末側の状態です。僕のプロセスが死んでも誰も戻してくれません。子プロセスも同じで、放っておけば ffplay は最後まで鳴り続けます。

というわけで終了時に必ず通したい処理があるのですが、この「終了時」が1つではありませんでした。

終わり方きっかけ
正常終了ffmpeg が全フレームを出し終えてパイプが閉じる
エラー終了パイプの読み込みに失敗する
中断Ctrl+C が押される

3つとも、カーソルを戻して子プロセスを片付けてから終わってほしい。ならば defer に書けば済むはずでした。

置いたはずの defer が走らない

後始末を defer に書いて、Ctrl+C を試します。カーソルは消えたままでした。

当時の僕は defer を「関数の終了時に実行される文」だと思っていました。プログラムが終わるのなら、どう終わろうと走るものだと。

走りませんでした。

Go のデフォルトでは、SIGINT を受け取ったプロセスはその場で終了します。関数から戻るのではなく途中で消えるので、defer は積まれたまま実行されません。

同じことがエラー経路でも起きていました。当時のエラー処理は log.Fatal です。

if err != nil {
	log.Fatal(err)
}

log.Fatal は中で os.Exit(1) を呼びます。そして os.Exitdefer を実行しません。

panic のほうが行儀がいい

わかりやすいのは、この2つを並べたときです。

func main() {
	defer fmt.Println("cleanup")
	panic("boom") // cleanup は出る
}
func main() {
	defer fmt.Println("cleanup")
	log.Fatal("boom") // cleanup は出ない
}

異常事態に見える panic のほうが後始末を通って、穏当に見える log.Fatal が通りません。「プログラムが終わるかどうか」で考えていると、この順番は説明できませんでした。

defer が走る条件はプログラムが終わるかどうかではなく、その関数から呼び出し元に戻るかどうかです。panic はスタックを巻き戻しながら落ちていくので、途中の defer は全部走ります。os.Exit は巻き戻さずにプロセスごと消えるので、1つも走りません。

全部 return に翻訳する

そう考えると、3つの終わり方のうち2つが「関数から戻らない終わり方」でした。

終わり方当時の実装巻き戻るか
正常終了break でループを抜ける
エラー終了log.Fatal
中断Go のデフォルト動作

やるべきことは「後始末を1箇所にまとめる」ではありませんでした。「全部の終わり方を return に翻訳する」でした。

エラーは log.Fatal をやめて return err にします。中断は signal.Notify で SIGINT を受け取り、ループの先頭で覗いて return errInterrupted にします。

ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
for {
	select {
	case <-ch:
		return errInterrupted
	default:
	}

signal.Notify を「シグナルを捕まえる仕組み」だと思っていましたが、ここでの役割は「プロセスを即死させる終わり方を、関数から戻る終わり方に変換すること」でした。

あとは os.Exit を呼ぶ場所を1箇所に寄せます。処理は全部 run() error に入れて、main は受け取ったエラーを表示して終了するだけにしました。

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

deferrun の中に置いてあるので、3つの終わり方すべてで走ります。os.Exit を呼ぶ main には defer を置きません。

//カーソルを非表示
fmt.Print("\033[?25l")
defer fmt.Print("\033[?25h")

ffplay を殺したのは僕じゃない

Ctrl+C を押すと、ffmpeg も ffplay もちゃんと止まります。しばらく満足していたのですが、コードを読み返して気づきました。僕は子プロセスに Kill() を呼んでいません。errInterrupted を返す経路は Wait() すら通りません。

止めていたのは端末でした。Ctrl+C で発生した SIGINT は、フォアグラウンドプロセスグループ全体に送られます。exec.Command で起動した子プロセスは、何も指定しなければ親と同じプロセスグループに入ります。つまり ffmpeg と ffplay には、僕を経由せずに直接シグナルが届いていました。

3つのプロセスが同時に SIGINT を受け取って、そのうち僕だけが signal.Notify でデフォルト動作を上書きしている。だから僕だけが即死せず、後始末をしてから終われる。そういう形になっていました。

裏を返すと、Ctrl+C 以外で落ちたときは誰も ffplay を止めてくれません。エラーで run を抜ける経路は Wait()Kill() も通らないので、映像が消えたあとに音だけが残るはずです。

ここはまだ直していません。頻繁に踏む経路ではないうえ、直すなら「音を止める」を3つの終わり方すべてに通す話になって、それはもう一度この記事と同じことをやり直す作業だからです。分かっていて残している、とだけ書いておきます。

おわりに

重い処理を外部プロセスに任せると、書くコードは確かに減ります。減った代わりに、自分の書いた関数の中で完結しないものが増えました。端末の状態も、子プロセスの生死も、僕の return の外側にあります。

今回いちばん時間を使ったのは AA 化ではなく、その外側に手を伸ばすための出口を1つに揃える作業でした。