Closed29

「[試して理解]Linuxのしくみ」をRustでやってみる(1〜4章)

(love cat)(love cat)

最近出版されたこちらの本をやっていきます。
ただの写経になってしまうとつまらない(かつ自分は思考停止して写経しがち)のでRustで書けるだけ書いてLinuxとRustについて鍛えます。
環境はラズパイの上でやっていくつもりで、推奨環境でないのでRust以前の問題としてうまく行かないかもしれないけどそれも一興ということで。

(love cat)(love cat)

sar: システムに搭載されている論理CPUが実行している命令の割合を表示する
"sar -P 0 1 1"なら論理CPU0を対象に、1秒に、1回データ採取。
ひたする無限ループするプログラムを走らせて、%userの割合が100%になることを確認。

neko@raspberrypi:~/linux/01 $ cat ./inf-loop.rs 
fn main() {
    loop {}
}
neko@raspberrypi:~/inux/01 $ taskset -c 0 ./inf-loop.out &
[1] 32097
neko@raspberrypi:~/linux/01 $ sar -P 0 1 1
Linux 5.15.61-v8+ (raspberrypi) 	20/10/22 	_aarch64_	(4 CPU)

01:07:43        CPU     %user     %nice   %system   %iowait    %steal     %idle
01:07:44          0     99.01      0.00      0.99      0.00      0.00      0.00
Average:          0     99.01      0.00      0.99      0.00      0.00      0.00
neko@raspberrypi:~/linux/01 $ sar -P 0 1 1
Linux 5.15.61-v8+ (raspberrypi) 	20/10/22 	_aarch64_	(4 CPU)

01:07:58        CPU     %user     %nice   %system   %iowait    %steal     %idle
01:07:59          0    100.00      0.00      0.00      0.00      0.00      0.00
Average:          0    100.00      0.00      0.00      0.00      0.00      0.00
neko@raspberrypi:~/linux/01 $ 
(love cat)(love cat)

次はシステムコールを無限ループの中で呼んで、%systemが上昇することを確かめたい。
nixというライブラリがいいらしいが、思いつきで始めたのでcargo newで作成したプロジェクト内にいないし、1ファイルだけで書き捨てる感じに書けないかな…と調べたらrust-scriptというものがあるらしい?
こっちのほうがよっぽど面倒くさくなる気がしなくもないが、せっかくなので試してみる。
https://crates.io/crates/rust-script

(love cat)(love cat)
syscall-inf-loop.rs
#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! nix = "0.25.0"
//! ```

fn main() {
    loop {
        nix::unistd::getppid();
    }
}
neko@raspberrypi:~/linux/01 $ taskset -c 0 ./syscall-inf-loop.rs &
[1] 2211
neko@raspberrypi:~/linux/01 $ sar -P 0 1 1
Linux 5.15.61-v8+ (raspberrypi) 	20/10/22 	_aarch64_	(4 CPU)

02:47:24        CPU     %user     %nice   %system   %iowait    %steal     %idle
02:47:25          0     48.00      0.00     52.00      0.00      0.00      0.00
Average:          0     48.00      0.00     52.00      0.00      0.00      0.00
neko@raspberrypi:~/linux/01 $ 
(love cat)(love cat)

もっと簡単に書けるみたい。

syscall-inf-loop.rs
#!/usr/bin/env rust-script
// cargo-deps: nix = "0.25.0"

fn main() {
    loop {
        nix::unistd::getppid();
    }
}
(love cat)(love cat)

公式ドキュメントより

As seen from the above example, using a fn main() {} function is not required. If not present, the script file will be wrapped in a fn main() { ... } block.

らしい。ということでこれも動く。

syscall-inf-loop.rs
#!/usr/bin/env rust-script
// cargo-deps: nix = "0.25.0"
loop {
    nix::unistd::getppid();
}

でもシンタックスエラーでrust-analyzerから怒られるし、どうやらmainで包むときに最終行にOk(())を足しているみたいで実行時にwarningが出る。
まあmainぐらいなら自分でどうにか記述してもいいかという気分になった。

(love cat)(love cat)

静的ライブラリと共有ライブラリ

Goは基本的に静的ライブラリのみを使用しているらしい。
Rustはどうだろうと見たら普通に共有ライブラリ使ってた。
デフォルトだとglibcを使用し、静的ライブラリであるmuslを使用したい場合は指定する必要があるとのこと。

(love cat)(love cat)

プロセス管理(基礎編)

forkはunsafe。ググったらどうしてなのかまとめてくださっているスクラップがあったので後でリンク先もちゃんと読んで理解する。
https://zenn.dev/helloyuki/scraps/86384c534e63ac
ということでnixのサンプルコードにはprintln!を使うなと注意があるのだが無視。

fork.rs
#!/usr/bin/env rust-script
// cargo-deps: nix, libc

use nix::{sys::wait::waitpid,unistd::{fork, ForkResult, getppid, getpid}};

match unsafe{fork()} {
   Ok(ForkResult::Parent { child, .. }) => {
       println!("親プロセス: pid={}, 子プロセスのpid={}", getpid(), child);
       waitpid(child, None).unwrap();
   }
   Ok(ForkResult::Child) => {
       // Unsafe to use `println!` (or `unwrap`) here. See Safety.
       // write(libc::STDOUT_FILENO, "I'm a new child process\n".as_bytes()).ok();
       println!("子プロセス: pid={}, 親プロセスのpid={}", getpid(), getppid());
       unsafe { libc::_exit(0) };
   }
   Err(_) => unsafe { libc::_exit(1) }
}
neko@raspberrypi:~/linux/02 $ ./fork.rs 
親プロセス: pid=4675, 子プロセスのpid=4704
子プロセス: pid=4704, 親プロセスのpid=4675
neko@raspberrypi:~/linux/02 $ 
(love cat)(love cat)

次はexecve。forkと違ってプロセスを上書きするのでforkしてから子プロセスで呼びましょうねという話。

fork-and-exec.rs
#!/usr/bin/env rust-script
// cargo-deps: nix, libc

use nix::{sys::wait::waitpid,unistd::{fork, ForkResult, getppid, getpid, execve}};
use std::ffi::CString;

match unsafe { fork() } {
    Ok(ForkResult::Parent { child, .. }) => {
        println!("親プロセス: pid={}, 子プロセスのpid={}", getpid(), child);
        waitpid(child, None).unwrap();
    }
    Ok(ForkResult::Child) => {
        let cmd = CString::new("/bin/echo").expect("CString::new failed");
        let args = [
            CString::new("echo").expect("CString::new failed"),
            CString::new(format!("pid={} からこんにちは", getpid()))
                .expect("CString::new failed"),
        ];
        let env = CString::new("").expect("CString::new failed");
        println!(
            "子プロセス: pid={}, 親プロセスのpid={}",
            getpid(),
            getppid()
        );
        execve(&cmd, &args, &[env]).expect("execve failed");
    }
    Err(_) => unsafe { libc::_exit(1) },
}
neko@raspberrypi:~/linux/02 $ ./fork-and-exec.rs 
親プロセス: pid=5999, 子プロセスのpid=6046
子プロセス: pid=6046, 親プロセスのpid=5999
pid=6046 からこんにちは
neko@raspberrypi:~/linux/02 $ 
(love cat)(love cat)

execve()の実現のために、実行ファイルは下記のようなデータを保持する。

  • コード領域のにある上オフセット、サイズ、メモリマップ開始アドレス
  • データ領域についてのお樹機と同じ情報
  • エントリポイント
(love cat)(love cat)

プロセスの終了

プロセスを終了させるためにはexit_group()というシステムコールが呼ばれる。
exit()関数で呼ばれるし、何もしなくても呼ばれる。
ここでカーネルはプロセスのリソースを回収する。

プロセス終了後に親はwait()などで子プロセス情報を得られるが、これは終了から親が終了状態を得るまではゾンビプロセス状態で残っているから。

(love cat)(love cat)

シグナル

Rustでシグナルハンドラを使うためにはctrlcというクレイトもあるが、もっと広範囲のシグナルに対応しているっぽいsignal_hookを使ってみる。

use std::io::Error;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use signal_hook::consts::signal::*;

fn main() -> Result<(), Error> {
    const SIGINT_U: usize = SIGINT as usize;
    let term = Arc::new(AtomicUsize::new(0));
    signal_hook::flag::register_usize(SIGINT, Arc::clone(&term), SIGINT_U)?;
    loop {
        match term.load(Ordering::Relaxed) {
            0 => (),
            SIGINT_U => (),
            _ => unreachable!(),
        }
    }
}
(love cat)(love cat)

セッション

ユーザが端末エミュレータ、あるいはsshなどでシステムにログインしたときのログインセッションにt対応するもの。
セッションにはSIDと呼ばれる一意な値が割り振られており、これはセッションリーダーのPIDと等しい。
セッションに紐付いている端末がハングアップするとセッションリーダーにSIGHUPが送られ、レーダーは自分の終了前に自分が管理するジョブを終了させる。

ということでセッションリーダーの一覧表示コマンドはこれでいいはず。自分が意識しているのはsshdとbashくらいだったのでデーモンを表示するために使うほうがいいかもしれない。

ps ajx | awk '{if($2==$4){print $0}}'
(love cat)(love cat)

プロセススケジューラ

システムに複数の実行可能プロセスがあるとき、カーネルはどのように各プロセスにCPUリソースを割り当てるのか、についての章。

まずはほどほどの時間動くプログラムを作るために何もせず所定回数ループするものを書く。rust-scriptはデフォルトでリリースビルドしてくれるらしく恐らく何もしないループはガン無視されるため、今回は普通にcargoでプロジェクト作ってデバッグビルドされたものを使う。

loop.rs
fn main() {
    let nloop: usize = 100000000;
    for _ in 0..nloop {}
}
neko@raspberrypi:~/linux/03/load/target/debug $ time ./load 

real	0m4.397s
user	0m4.392s
sys	0m0.005s
neko@raspberrypi:~/linux/03/load/target/debug $ 

遅い、やったぜ。

(love cat)(love cat)

まずは使用できるCPUを1つに制限した上で並列実行。1つ目の引数が実行される処理の個数を指定してる。

neko@raspberrypi:~/linux/03 $ ./multiload.sh 2 ./load/target/debug/load

real	0m8.725s
user	0m4.357s
sys	0m0.005s

real	0m8.728s
user	0m4.358s
sys	0m0.004s
neko@raspberrypi:~/linux/03 $
neko@raspberrypi:~/linux/03 $ ./multiload.sh 3 ./load/target/debug/load

real	0m13.093s
user	0m4.363s
sys	0m0.001s

real	0m13.092s
user	0m4.362s
sys	0m0.000s

real	0m13.096s
user	0m4.362s
sys	0m0.004s
neko@raspberrypi:~/linux/03 $ 

許しがたいほど遅い。

(love cat)(love cat)

複数CPU使用を許可するとちゃんと分散された模様。

neko@raspberrypi:~/linux/03 $ ./multiload.sh -m 2 ./load/target/debug/load

real	0m4.376s
user	0m4.372s
sys	0m0.004s

real	0m4.382s
user	0m4.375s
sys	0m0.004s
neko@raspberrypi:~/linux/03 $ ./multiload.sh -m 3 ./load/target/debug/load

real	0m4.403s
user	0m4.399s
sys	0m0.004s

real	0m4.405s
user	0m4.400s
sys	0m0.004s

real	0m4.404s
user	0m4.403s
sys	0m0.000s
neko@raspberrypi:~/linux/03 $ 
(love cat)(love cat)

論理コア数は4。5以上指定すると遅れだす。

neko@raspberrypi:~/linux/03 $ ./multiload.sh -m 5 ./load/target/debug/load

real	0m5.232s
user	0m4.379s
sys	0m0.005s

real	0m5.422s
user	0m4.381s
sys	0m0.004s

real	0m5.457s
user	0m4.367s
sys	0m0.004s

real	0m5.491s
user	0m4.372s
sys	0m0.008s

real	0m5.516s
user	0m4.354s
sys	0m0.001s
neko@raspberrypi:~/linux/03 $
(love cat)(love cat)

pythonのサンプルコードに比べると負荷処理1セットが遅い。1秒間に処理するループに約1000回も差がある。

書いてみたRustコード
sched.rs
use nix::{
    sched,
    sys::wait,
    unistd::{self, fork, ForkResult},
};
use std::time;
use std::{fs::File, io::Write};

const NLOOP_FOR_ESTIMATION: usize = 100000000;

#[derive(Debug)]
enum SchedError {
    FewArguments(usize),
    CannotParseParrallel,
}

fn main() -> Result<(), SchedError> {
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 2 {
        println!("引数が不足。");
        return Err(SchedError::FewArguments(args.len()));
    }
    let arg_1 = args.get(1).expect("");
    let concurrency = match arg_1.parse::<usize>() {
        Ok(x) => x,
        Err(_) => {
            println!("並列度は1以上の整数であること。");
            return Err(SchedError::CannotParseParrallel);
        }
    };
    let mut cpu_set = nix::sched::CpuSet::new();
    cpu_set.set(concurrency).expect("failed set cuncurrency");
    sched::sched_setaffinity(unistd::Pid::from_raw(0), &cpu_set).expect("failed shced setaffinity");
    let nloop_per_msec = estimate_loops_per_msec();

    let start = time::Instant::now();
    for i in 0..concurrency {
        match unsafe { fork() } {
            Ok(ForkResult::Parent { .. }) => (),
            Ok(ForkResult::Child) => {
                child_fn(i, nloop_per_msec, start);
            }
            Err(_) => unsafe { libc::_exit(1) },
        }
    }
    for _ in 0..concurrency {
        wait::wait().expect("wait failed");
    }
    Ok(())
}

fn estimate_loops_per_msec() -> u128 {
    let start = time::Instant::now();
    for _ in 0..NLOOP_FOR_ESTIMATION {}
    let end = time::Instant::now();
    NLOOP_FOR_ESTIMATION as u128 / end.duration_since(start).as_millis()
}

fn child_fn(n: usize, nloop: u128, start: time::Instant) {
    let mut progress: [Option<time::Instant>; 100] = [None; 100];
    for i in 0..100 {
        for _ in 0..nloop {}
        progress[i] = Some(time::Instant::now());
    }
    let mut f = File::create(format!("{}.data", n)).expect("create file failed");
    for i in 0..100 {
        writeln!(
            f,
            "{}\t{}",
            progress[i].unwrap().duration_since(start).as_secs_f64() * 1000.0,
            i
        )
        .expect("write file failed");
    }
    unsafe { libc::_exit(0) };
}
(love cat)(love cat)

メモリ管理システム

まずはプログラム実行中にメモリ獲得するとシステム全体のメモリ使用量が大きくなることの確認。

コード
memuse.rs
use std::process;
use std::io::{self, Write};

fn main() {
    let size = 10000000;
	println!("メモリ獲得前");
	let output = process::Command::new("free").output().expect("free failed");
	io::stdout().write_all(&output.stdout).unwrap();
	let _array = vec![1000 as i64; size];
	println!("メモリ獲得後");
	let output = process::Command::new("free").output().expect("free failed");
	io::stdout().write_all(&output.stdout).unwrap();
}
結果
memuse $ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s
     Running `target/debug/memuse`
メモリ獲得前
               total        used        free      shared  buff/cache   available
Mem:        15169600     8714352     1468112      140092     4987136     5982652
Swap:              0           0           0
メモリ獲得後
               total        used        free      shared  buff/cache   available
Mem:        15169600     8792188     1390276      140092     4987136     5904816
Swap:              0           0           0
memuse $
(love cat)(love cat)

仮想記憶

仮想記憶がないときの課題

メモリの断片化、マルチプロセスの実現が困難、不正な領域へのアクセスといったものがわかりやすい課題。

仮想記憶の機能

システムに搭載されているメモリに物理アドレスを使ってアクセスさせるのでなく、使用可能な範囲をアドレス空間として定め、仮想アドレスを用いて間接的にアドレスさせる。
仮想アドレスと物理アドレスの変換はカーネルのメモリ内に保存しているページテーブルを用いる。
アドレスはページ単位、1ページに対応するで0田はページテーブルエントリと呼ぶ。
ページのサイズはCPUアーキテクチャごとに決められている。

物理アドレスに紐付けられていない範囲にアクセスした場合、不正なメモリアクセスとしてページフォールト例外となり、おなじみSIGSEGVシグナルがそのプロセスに送信される。

仮想記憶による課題の解決

メモリの断片化←ページテーブルをうまいこと設定して解決。
マルチプロセスの実現が困難←プロセスごとに仮想アドレス空間を作りアドレスを割り振る。
不正な領域へのアクセス←各プロセスにアクセスされてもいいところだけ各仮想アドレス空間に割り振る。

(love cat)(love cat)

とはいえ流石にあからさまにNULLポインターに書き込もうとするとおいおいとなるみたいだ。

segv $ cargo run
warning: dereferencing a null pointer
 --> src/main.rs:3:11
  |
3 |     unsafe { *(0 as *mut u32) = 42; }
  |              ^^^^^^^^^^^^^^^^ this code causes undefined behavior when executed
  |
  = note: `#[warn(deref_nullptr)]` on by default

warning: `segv` (bin "segv") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/segv`
不正メモリアクセス前
Segmentation fault (コアダンプ)
segv $ 

上でツッコまれてる0としているところを1にすればスムーズに(?)セグフォ。

segv $ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/segv`
不正メモリアクセス前
Segmentation fault (コアダンプ)
segv $ 

そういえば4章に入ってからmanjaroで実行してしまっている。備忘。

(love cat)(love cat)

プロセスへの新規メモリの割当

Linuxではメモリ獲得は

  1. メモリ領域の割当
  2. メモリの割当
    の2手順からなる。

メモリ領域の割当はmmap()システムコールでなされる。

(love cat)(love cat)

これでいいのかな?

mmap.rs
use nix::sys::mman::MapFlags;
use nix::sys::mman::ProtFlags;
use nix::libc::size_t;
use nix::unistd::getpid;
use nix::sys::mman::mmap;
use std::process;
use std::ptr;
use std::io::{self, Write};

fn main() {
	const ALLOC_SIZE: size_t = 1024 * 1024 * 1024;
	let pid = getpid();
    println!("新規メモリ領域獲得前のメモリマップ");
	let command = process::Command::new("cat").arg(format!("/proc/{}/maps", pid)).output().expect("cat failed");
	io::stdout().write_all(&command.stdout).expect("write stdout failed");

	// mmap()の使い方:https://kazmax.zpp.jp/cmd/m/mmap.2.html
	let data = unsafe {
		mmap(
                // 大した意味なし、0でいい
		ptr::null_mut(),
                // メモリにマップするサイズ 
		ALLOC_SIZE,
                // メモリ保護の指定
		ProtFlags::PROT_READ | ProtFlags::PROT_WRITE, 
		// マップされたオブジェクトのタイプ、マップ時のオプション、
		// マップされたページコピーへの変更を そのプロセスだけが行えるのかを指定する
		MapFlags::MAP_ANON | MapFlags::MAP_PRIVATE,
		// MAP_ANONがセットされた場合は-1にするとだけまず覚えておく
		-1,
		// ページサイズの整数倍であること
		0)
		.expect("mmap failed")
	}; 	
	println!("新規メモリ領域: アドレス = {:p}, サイズ = 0x{:x}", data, ALLOC_SIZE);
	
	println!("新規メモリ領域獲得後のメモリマップ");
	let command = process::Command::new("cat").arg(format!("/proc/{}/maps", pid)).output().expect("cat failed");
	io::stdout().write_all(&command.stdout).expect("write stdout failed");

}
結果
mmap $ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/mmap`
新規メモリ領域獲得前のメモリマップ
...(省略)
564b52ce8000-564b52d09000 rw-p 00000000 00:00 0                          [heap]
7fc8342cc000-7fc8342cf000 rw-p 00000000 00:00 0 
...(省略)
新規メモリ領域: アドレス = 0x7fc7f42cc000, サイズ = 0x40000000
新規メモリ領域獲得後のメモリマップ
...(省略)
564b52ce8000-564b52d09000 rw-p 00000000 00:00 0                          [heap]
7fc7f42cc000-7fc8342cf000 rw-p 00000000 00:00 0 
...(省略)
mmap $ 

もともと0x3000バイトしか空いてなかった場所が0x40003000バイト空いている。これでいいみたいだ。

(love cat)(love cat)

メモリの割当

mmap()システムコールはあくまでプロセスにメモリ領域を割り当てただけで、そこに対応する物理メモリはまだない。
新規獲得領域の各ページに最初にアクセスしたときに物理メモリを割り当てる(デマンドページング)。
これを実現するために、メモリ管理システムは各ページに、物理メモリを割り当て済みかという状態を持っている。

mmap()システムコールで割り当てられたページにプロセスがアクセスすると、下記の流れでメモリが割り当てられる。

  1. ページフォールト発生
  2. カーネルのページフォールトハンドラが動作して、ページに対応する物理メモリを割り当てる。
(love cat)(love cat)

下記のコードを流しながら"sar -r 1"とか"sar -B 1"を実行してメモリ使用量とかページフォールト発生回数を見る。
ちょっと長いので折りたたみ。

mmap()を呼び出して得た領域に10MiBづつアクセスするコード
demand-paging.rs
use nix::libc::size_t;
use nix::sys::mman::{mmap, MapFlags, ProtFlags};
use std::io::{self, Read};
use std::os::raw::c_int;
use std::ptr;

fn main() {
    const ALLOC_SIZE: size_t = 100 * 1024 * 1024;
    const ACCESS_UNIT: size_t = 10 * 1024 * 1024;
    const PAGE_SIZE: size_t = 4096;
    println!("新規メモリ領域獲得前。Enterキーを押すと100MiBの新規メモリ領域を獲得します:");
    let _ = io::stdin().lines();
    let mut memregion = unsafe {
        mmap(
            ptr::null_mut(),
            ALLOC_SIZE,
            ProtFlags::PROT_READ | ProtFlags::PROT_WRITE,
            MapFlags::MAP_ANON | MapFlags::MAP_PRIVATE,
            -1,
            0,
        )
        .expect("mmap failed")
    };

    println!("新規メモリ領域を獲得しました。Enterキーを押すと1秒に10MiBづつ、合計100MiBの新規メモリ領域にアクセスします:");
    let _ = io::stdin().read(&mut [0u8]).expect("waiting enter failed");

    for i in (0..ALLOC_SIZE).step_by(PAGE_SIZE) {
        unsafe {
            let target_address = memregion as *mut c_int;
            *(target_address) = 0;
            memregion = memregion.add(PAGE_SIZE);
        }

        if i % ACCESS_UNIT == 0 && i != 0 {
            println!(
                "{}: {} MiBアクセスしました",
                chrono::Utc::now().format("%H:%M:%S"),
                i / 1024 / 1024
            );
            std::thread::sleep(std::time::Duration::from_secs(1));
        }
    }
    println!(
        "{}: 新規獲得したメモリ領域のすべてにアクセスしました。Enterキーを押すと終了します:",
        chrono::Utc::now().format("%H:%M:%S").to_string()
    );
    let _ = io::stdin().read(&mut [0u8]).expect("waiting enter failed");
}
このスクラップは2022/10/25にクローズされました