「[試して理解]Linuxのしくみ」をRustでやってみる(1〜4章)
最近出版されたこちらの本をやっていきます。
ただの写経になってしまうとつまらない(かつ自分は思考停止して写経しがち)のでRustで書けるだけ書いてLinuxとRustについて鍛えます。
環境はラズパイの上でやっていくつもりで、推奨環境でないのでRust以前の問題としてうまく行かないかもしれないけどそれも一興ということで。
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 $
次はシステムコールを無限ループの中で呼んで、%systemが上昇することを確かめたい。
nixというライブラリがいいらしいが、思いつきで始めたのでcargo newで作成したプロジェクト内にいないし、1ファイルだけで書き捨てる感じに書けないかな…と調べたらrust-scriptというものがあるらしい?
こっちのほうがよっぽど面倒くさくなる気がしなくもないが、せっかくなので試してみる。
#!/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 $
もっと簡単に書けるみたい。
#!/usr/bin/env rust-script
// cargo-deps: nix = "0.25.0"
fn main() {
loop {
nix::unistd::getppid();
}
}
公式ドキュメントより
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.
らしい。ということでこれも動く。
#!/usr/bin/env rust-script
// cargo-deps: nix = "0.25.0"
loop {
nix::unistd::getppid();
}
でもシンタックスエラーでrust-analyzerから怒られるし、どうやらmainで包むときに最終行にOk(())を足しているみたいで実行時にwarningが出る。
まあmainぐらいなら自分でどうにか記述してもいいかという気分になった。
静的ライブラリと共有ライブラリ
Goは基本的に静的ライブラリのみを使用しているらしい。
Rustはどうだろうと見たら普通に共有ライブラリ使ってた。
デフォルトだとglibcを使用し、静的ライブラリであるmuslを使用したい場合は指定する必要があるとのこと。
プロセス管理(基礎編)
forkはunsafe。ググったらどうしてなのかまとめてくださっているスクラップがあったので後でリンク先もちゃんと読んで理解する。
ということでnixのサンプルコードにはprintln!を使うなと注意があるのだが無視。#!/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 $
次はexecve。forkと違ってプロセスを上書きするのでforkしてから子プロセスで呼びましょうねという話。
#!/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 $
execve()の実現のために、実行ファイルは下記のようなデータを保持する。
- コード領域のにある上オフセット、サイズ、メモリマップ開始アドレス
- データ領域についてのお樹機と同じ情報
- エントリポイント
プロセスの終了
プロセスを終了させるためにはexit_group()というシステムコールが呼ばれる。
exit()関数で呼ばれるし、何もしなくても呼ばれる。
ここでカーネルはプロセスのリソースを回収する。
プロセス終了後に親はwait()などで子プロセス情報を得られるが、これは終了から親が終了状態を得るまではゾンビプロセス状態で残っているから。
シグナル
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!(),
}
}
}
セッション
ユーザが端末エミュレータ、あるいはsshなどでシステムにログインしたときのログインセッションにt対応するもの。
セッションにはSIDと呼ばれる一意な値が割り振られており、これはセッションリーダーのPIDと等しい。
セッションに紐付いている端末がハングアップするとセッションリーダーにSIGHUPが送られ、レーダーは自分の終了前に自分が管理するジョブを終了させる。
ということでセッションリーダーの一覧表示コマンドはこれでいいはず。自分が意識しているのはsshdとbashくらいだったのでデーモンを表示するために使うほうがいいかもしれない。
ps ajx | awk '{if($2==$4){print $0}}'
プロセススケジューラ
システムに複数の実行可能プロセスがあるとき、カーネルはどのように各プロセスにCPUリソースを割り当てるのか、についての章。
まずはほどほどの時間動くプログラムを作るために何もせず所定回数ループするものを書く。rust-scriptはデフォルトでリリースビルドしてくれるらしく恐らく何もしないループはガン無視されるため、今回は普通にcargoでプロジェクト作ってデバッグビルドされたものを使う。
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 $
遅い、やったぜ。
まずは使用できる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 $
許しがたいほど遅い。
複数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 $
論理コア数は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 $
pythonのサンプルコードに比べると負荷処理1セットが遅い。1秒間に処理するループに約1000回も差がある。
書いてみたRustコード
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) };
}
プロットを書くようなものはあまりやる気が出ないので一旦やらない(言い訳)。
使うとしたらplottersというcrateが一番メジャーなのかな。
メモリ管理システム
まずはプログラム実行中にメモリ獲得するとシステム全体のメモリ使用量が大きくなることの確認。
コード
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 $
プロセスの削除によるメモリの強制回収
簡単なメモリの監視方法としてはpsコマンドのRSSフィールド(プロセスが使っているメモリ量)をみる。覚えた。
今すぐ読むつもりはないけど、OOM killerについての記事とのこと。
仮想記憶
仮想記憶がないときの課題
メモリの断片化、マルチプロセスの実現が困難、不正な領域へのアクセスといったものがわかりやすい課題。
仮想記憶の機能
システムに搭載されているメモリに物理アドレスを使ってアクセスさせるのでなく、使用可能な範囲をアドレス空間として定め、仮想アドレスを用いて間接的にアドレスさせる。
仮想アドレスと物理アドレスの変換はカーネルのメモリ内に保存しているページテーブルを用いる。
アドレスはページ単位、1ページに対応するで0田はページテーブルエントリと呼ぶ。
ページのサイズはCPUアーキテクチャごとに決められている。
物理アドレスに紐付けられていない範囲にアクセスした場合、不正なメモリアクセスとしてページフォールト例外となり、おなじみSIGSEGVシグナルがそのプロセスに送信される。
仮想記憶による課題の解決
メモリの断片化←ページテーブルをうまいこと設定して解決。
マルチプロセスの実現が困難←プロセスごとに仮想アドレス空間を作りアドレスを割り振る。
不正な領域へのアクセス←各プロセスにアクセスされてもいいところだけ各仮想アドレス空間に割り振る。
Rustでセグフォにするコードたち。
とはいえ流石にあからさまに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で実行してしまっている。備忘。
プロセスへの新規メモリの割当
Linuxではメモリ獲得は
- メモリ領域の割当
- メモリの割当
の2手順からなる。
メモリ領域の割当はmmap()システムコールでなされる。
引数の説明がない。本家と変わらないのでわざわざ説明しないということか。
これでいいのかな?
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バイト空いている。これでいいみたいだ。
メモリの割当
mmap()システムコールはあくまでプロセスにメモリ領域を割り当てただけで、そこに対応する物理メモリはまだない。
新規獲得領域の各ページに最初にアクセスしたときに物理メモリを割り当てる(デマンドページング)。
これを実現するために、メモリ管理システムは各ページに、物理メモリを割り当て済みかという状態を持っている。
mmap()システムコールで割り当てられたページにプロセスがアクセスすると、下記の流れでメモリが割り当てられる。
- ページフォールト発生
- カーネルのページフォールトハンドラが動作して、ページに対応する物理メモリを割り当てる。
下記のコードを流しながら"sar -r 1"とか"sar -B 1"を実行してメモリ使用量とかページフォールト発生回数を見る。
ちょっと長いので折りたたみ。
mmap()を呼び出して得た領域に10MiBづつアクセスするコード
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");
}