LoginSignup
43
22

More than 3 years have passed since last update.

噂のpidfdをRustで試してみた

Posted at

はじめに

Python3.9の新機能 (まとめ)という記事を書く中でpidfdというものを知りました。まだあまり良い解説記事がないのですが、最近の5.x系Linuxカーネルに新たに導入されたプロセスを識別するための仕掛けです。この機能がRust言語でCrate(ライブラリのようなもの)として実装されていたので試してみました。

pidfdとは

これまでプロセスはPID(Process ID)によって指し示されていました。PIDはプロセスを一意に指し示すための識別子で、例えばシグナルなどを送ったりするのに使われます。

PIDは32bitの整数で、プロセスが生成される時にカーネルが割り振りますが、システム全体で共有しているので枯渇しちゃいます。32bitならそこそこあるように思いますが、実はPIDの最大値はもっと小さく設定されています。Linuxの場合は cat /proc/sys/kernel/pid_max で見ることができますが、手元のUbuntuだと 32768 でした。これを越えるとどうなるか? 過去につかったPIDを再利用することになります。

ところがこれはセキュリティ的な問題をはらんでいます。例えば、PID=12345が自分の信頼できるプロセスだと思っていたら、いつの間にか同じIDなのに違うプロセスになっていた、ということが起こりうるからです。そもそもIDっていうのに再利用しちゃうってまずいですよね。

人の世界では例えば電話番号とかでも同じことが起きますが、電話の場合は相手が出たら声で「こいつ違うな」ってわかりますが、Unixのプロセスの世界ではそう簡単にはわからないかも知れない。

それでこの問題を避けるために pidfd というものが導入されました。これにより、これまでシステム全体で一つの番号体系だったものをプロセスごとの番号体系になります。もちろんPIDはなくならないけど、親プロセスと子プロセスのやり取りに関してはこちらを使ったほうが安全で確実です。

そして、プロセスのために新たなID体系を作るのではなく、既にUnixにあるものを流用しています。File Descriptorですね。pidfdの"fd"はここから取られているみたいです。Unixに昔からある Everything is a file の考え方をプロセスまで拡張した感じですかね。

pidfdを得るには3つくらい方法があります。

  1. /proc/[pid] ディレクトリをopen()する
  2. pidfd_open() システムコールを使う
  3. clone()clone3()CLONE_PIDFDフラグをつけて呼び出す

そして、得られた pidfdを

pidfd_send_signal(int pidfd, int sig, siginfo_t *info, unsigned int flags);

に与えるとシグナルを送れたりします。ファイル感覚でプロセスを扱えるってなんか新鮮ですね。

仕様をみているだけだとつまらないのでちょっと試してみたくなったのですが、C言語で書くというのもあまり芸がない。と思っていたらRustの実装があったのでそちらを使ってみました。

試してみたこと

環境の準備

新しいLinuxカーネルが必要とのことでなにかのディストリビューションを入れたあとにカーネルアップグレードしなきゃかなと思ったのですが、Ubuntuの最新版である 19.10 (Eoan Ermine)がLinux 5.3を採用している(2020-01-19時点で apt upgradeすると linux-image-5.3.0-26)ということがわかり、これで試しています。

なお、久しぶりにUbuntuのインストール(server版)をやりましたが、インストーラも進化してますね。中でも、Githubから公開鍵1をサクッと取ってきてくれたりするのは結構うれしかったです。

pidfd版

pidfdのCrateは https://crates.io/crates/pidfd にあります。cargo newでプロジェクトを作り、Cargo.tomlに最新のpidfdと一緒に使うfuturesを依存関係として入れておきます。

[dependencies]
pidfd = "0.2.4"
futures = "0.3.1"

テストに使ったソースはこのような感じです。ほぼほぼサンプルプログラムのままですが、途中でpidfdの値を取りたかったので少しだけ改造してあります。

use pidfd::PidFd;
use std::{io, process::Command};
use std::os::unix::io::AsRawFd;

fn main() {
    futures::executor::block_on(async move {
        futures::try_join!(
            spawn_sleeper("1", "5"),
            spawn_sleeper("2", "4"),
            spawn_sleeper("3", "3"),
            spawn_sleeper("4", "2"),
            spawn_sleeper("5", "1"),
        )
        .unwrap();
    })
}

async fn spawn_sleeper(id: &str, timeout: &str) -> io::Result<()> {
    println!("started job {}", id);

    let pidfd = Command::new("/bin/sleep")
        .arg(timeout)
        .spawn()
        .map(PidFd::from)
        .unwrap();

    println!("pidfd={:?}", pidfd.as_raw_fd());

    let exit_status = pidfd.into_future()
        .await?;

    println!("finished job {}: {}", id, exit_status);
    Ok(())
}

動きを少しだけ解説しておくと、mainspawn_sleeperという非同期関数を5回呼び出して try_join!でそれが終わるのを待つだけです。spawn_sleeperは、idtimeoutという2つの引数を取り、まずはidをプリントアウトしたあと、/bin/sleepを呼び出して与えられたtimeout秒だけ待って終了という関数です。

pidfdはCommand:newで子プロセスを作ったあとにそれをPidFd::fromに渡して取得しています。それのraw_id(OSから渡された生のID)を表示したあと、Futureに変換してawaitします。timeout後に子プロセスが終了したら、再びidと終了コードを表示して完了という流れです。

これを実行してみるとこうなります。

% cargo run
started job 1
pidfd=3
started job 2
pidfd=6
started job 3
pidfd=7
started job 4
pidfd=8
started job 5
pidfd=9
finished job 5: exit code: 0
finished job 4: exit code: 0
finished job 3: exit code: 0
finished job 2: exit code: 0
finished job 1: exit code: 0

動きをお見せできないのは残念ですが、pidfd=9までは一気に表示され、あとは一秒ごとにfinished job ...が表示されます。というのも、spawn_sleepersawitの手前までは一気に進み、そこまで行くと処理がyieldされて次のspawn_sleeper呼び出しがされるから。これを5回繰り返して待ち状態になります。そしてtimeoutの指定が逆順になっているので最後に呼び出したid=5から順次逆順で処理を完了していきます。

これだけだと通常の非同期プログラミングのデモになっちゃうので、pidfd= の行を見ていただきたいです。繰り返し実行するとわかるのですが、これらの番号は基本的に変わりません。0, 1, 2 は標準入力、標準出力、標準エラーに割りあたっているので、3番から振られるのは普通のファイルと変わりません。途中の4, 5 が抜けているのは別のファイルオープンとかがあったのかもです。

tokio版

これだけだと何なので、pidfdを使わない実装というのもやってみました。tokioというCrateを使っています。tokio は "A runtime for writing reliable asynchronous applications with Rust." (信頼できる非同期アプリをRustで書くための実行環境)で、実際Rustでは人気のCrateの一つです。

これを使ってみるとこんな風になりました。

use tokio::process::Command;
use futures::try_join;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    try_join! (
        spawn_sleeper("1", "5"),
        spawn_sleeper("2", "4"),
        spawn_sleeper("3", "3"),
        spawn_sleeper("4", "2"),
        spawn_sleeper("5", "1"),
    ).unwrap();

    Ok(())
}

async fn spawn_sleeper(id: &str, timeout: &str) -> Result<(), Box<dyn std::error::Error>> {
    println!("started job {}", id);

    let child = Command::new("/bin/sleep")
        .arg(timeout)
        .spawn()
        .unwrap();

    println!("pid = {}", child.id());

    let exit_status = child.await?;

    println!("finished job {}: {}", id, exit_status);

    Ok(())
}

spawn_sleeper の中身を見てみると、Command:newからarg()spawn()するまでは同じコード。そのあと unwrap()して idを表示させてから、await?で終了を待ちます。ここで Childをそのまま awaitで待つのは不思議だなと一瞬思いますが、冒頭の use文を見てみると use tokio::process::Command; となっていて標準のuse std::process::Command;とは違うものになっています。それ以外はごく自然なコードですね。

結果はこのようになります。

started job 1
pid = 16850
started job 2
pid = 16851
started job 3
pid = 16852
started job 4
pid = 16853
started job 5
pid = 16854
finished job 5: exit code: 0
finished job 4: exit code: 0
finished job 3: exit code: 0
finished job 2: exit code: 0
finished job 1: exit code: 0

pidfd版と同じで10行目まで一気に表示し、その後は一秒ごとに一行ずつ表示されます。pidfd版との違いはidの部分で、pidが5桁の数字で表現されています。何回か実行するとわかりますが、この数字は実行するたびに変わります。そして上限の32768を越えるとまた若い番号に戻るはずです。

なお、ご参考までに今回のこの例は以下のリポジトリに入れてあります。
https://github.com/ksato9700/pidfd_rust_test

まとめ

Linuxに新たに導入されたpidfdを調べて、そして使ってみました。まだ日本語の解説記事とか少ないと思いますが、英語だとこのlwn.netの記事なんかは良くまとまっているかなと思います。

そして、Pythonでは asyncioの子プロセスを監視するための仕掛けとして 3.9から導入されるようですが、そのうち Subprocess モジュールとかでも使われるようになるのかも知れません。Linux限定というところが微妙ではあるのですが...


  1. Githubの公開鍵はhttps://github.com/<user-name>.keysで公開されているのでした  

43
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
22