Open8

『並行プログラミング入門』読書メモ+Rustハンズオン

ピン留めされたアイテム
_5da_5da

はじめに

『並行プログラミング入門 ―Rust、C、アセンブリによる実装からのアプローチ 』
の読書メモ+Rustハンズオンです

https://amzn.asia/d/3JFXYvl

コメントルール

  • 3ポイント
    • 1回のコメントにつき3点、良いな|覚えておこうと思ったポイントを記載します
  • 1アクション
    • その時までに読んだ内容を参考にして、実際にコードを書いて動かして学びます
_5da_5da

p1~10

Points

  1. p7 計算量の少ない問題では、スレッド生成や同期処理のオーバーヘッドが大きいため高速化には寄与せず、逆に1ステップずつ計算するよりも遅くなるため注意が必要である

  2. p8 一般的に、並列化不可能な処理の割合の方が、並列化可能な処理の割合よりも十分に小さいときに並列化による高速化が有効に働く

  3. p9 アムダールの法則にオーバーヘッドを考慮した場合の性能向上率を求める式

    \frac{1}{H+(1-P)+\frac{P}{N}}
  • P : 全体のプログラム中において並列化可能な処理が占める割合
  • N : 並列化の数
  • H : オーバーヘッドの応答速度と逐次実行したときの応答速度の比

mermaidでのグラフ記述内容
xychart-beta
    title "凡例 橙線 P=1.0 紺線 P=0.75 薄紫線 P=0.5"
    x-axis "並列化の数(N)" [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
    y-axis "高速化の倍率" 0 --> 12
line [1.0, 1.3333333333333333, 1.5, 1.6, 1.6666666666666667, 1.7142857142857142, 1.75, 1.7777777777777777, 1.7999999999999998, 1.8181818181818181, 1.8333333333333335, 1.8461538461538463]
line [1.0, 1.6, 2.0, 2.2857142857142856, 2.5, 2.6666666666666665, 2.8, 2.909090909090909, 3.0, 3.0769230769230766, 3.142857142857143, 3.2]
line [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0]

https://mermaid.js.org/syntax/xyChart.html

Action

_5da_5da

学習範囲:p11~38

Points

  1. p20 ポインタにつまづいた人は、まず、参考文献 [x86_64assembly], [pathene]などで、アセンブリやハードウェアについて理解した方がよい。(中略)
    また、ポインタ専用の本もあるため[pointer_oreilly]、それらを参考にするのもよいだろう。

    • 自分はpointer_oreillyだけ読んでましたが、他も気になる
  2. p27 ヒープメモリ上に確保した値の解放を忘れてしまうことはメモリリークと呼ばれ

    • スタックは関数抜けたときにメモリ解放されるので、気を付けるのは主にヒープになるのか
  3. p33 セミコロンの意味論的な話はλ計算等で形式的に説明すれば、もう少し厳密に理解できると思われるが、本書では割愛させてもらう。

    • 気になります。『記号と再帰』を積んでいるのを思い出したので近々読み進めます。

Action

  • p36~38で関数ポインタとクロージャについて解説されていて、少し理解が深まったのでコードを書いてみます
fn main() {
    let f1 = x_up(2);
    let f2 = double_up_with_dummy_arg(2);
    
    println!("{}",double_up(2));    // 4
    println!("{}",f1(0));           // 0
    println!("{}",f2(0));           // 4
}

fn double_up (n:u16) -> u16 {
    n * 2 
}

fn x_up (x:u16) -> Box::<dyn Fn(u16)->u16> {
    Box::new(move|y| x * y)
}

fn double_up_with_dummy_arg (x:u16) -> Box::<dyn Fn(u16)->u16> {
    Box::new(move|_| x * 2)
}
_5da_5da

学習範囲 p38~43

Points

  1. p38 関数の外で定義される変数を自由変数、関数の中で定義される変数を束縛変数と呼ぶ

    • 自由変数になりたい
  2. p41 以上がRustの所有権であるが、元となった線形論理と対比して考えることで、その意味するところが明確になる。多くのプログラミング言語では所有権の考えがないため、所有権の概念につまづく初学者が多いといわれているが、その大元の思想に立ち返ってみると非常に理にかなった考えであることがわかる

    • 線形論理という概念を初めて知る
  3. p43 Rustでは異なるライフタイムでも一方に合わせることができるようになっており、これは部分型付け(subtyping)と呼ばれる技術で実現している。元々はオブジェクト指向におけるクラスで多相性を実現するための型システムの一種であった

    • ほう

Action

  • ライフタイムについてのコード(省略)写経
fn main() {
    let x = 1;
    {
        let y = 2;
        let sum = add(&x,&y);
        let sum2 = add2(&x,&y);
        println!("{sum}");  // 3
        println!("{sum2}"); // 3
    }
    
    fn add<'a>(x:&'a i32, y:&'a i32)->i32{
        x+y
    }

    fn add2(x:&i32, y:&i32)->i32{
        x+y
    }
}
  • add2関数みたいにライフタイム省略しても動くけど、うまく説明できないな~とおもったのでChatGPTに聞いた

ありがとうございます

_5da_5da

学習範囲 p55~75

Points

  1. p55 Rust言語の同期処理ライブラリは裏側でPthreadsを利用している。

    • C言語さんに足向けて寝れない
  2. p57 アトミック処理(atomic operation)とは不可分操作とも呼ばれる処理であり、それ以上は分割不可能な処理のことを言う。(中略)

定義 : アトミック処理の性質
ある処理がアトミックである⇒その処理の途中状態はシステム的に観測することができず、かつ、もしその処理が失敗した場合は完全に処理前の状態に復元される。
- データベースで学ぶ概念だ

  1. p64 ミューテックスはMUTual EXclusion(mutex)の略であり、日本語だと排他実行とも呼ばれる同期処理の方法である。

  • 略語だったのね

Action

  • アトミックで遊んだ
use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let orders:Vec<Ordering> = vec![Ordering::AcqRel,Ordering::Acquire,Ordering::Relaxed,Ordering::Release,Ordering::SeqCst];
    for order in orders {
        let u_atom = AtomicUsize::new(0);
        let lap = std::time::Instant::now();
        for _ in 0..1_000_000_000{
            u_atom.fetch_add(1, order);
        }
    println!("{:?}\t{:?}",order,lap.elapsed());
    }
}

実行結果

AcqRel  951.2733ms
Acquire 979.2315ms
Relaxed 930.7686ms
Release 944.774ms
SeqCst  943.9852ms
_5da_5da

p76~

Points

  1. p90 ⑤Arc型の値のクローンをしても内部のデータコピーは行われず、参照カウンタがインクリメントされるのみ。

    • そうだったのか~!
    • Rc<T>とArc<T>はそういう仕様らしい
  2. p101 現代的なCPUでは、アウトオブオーダ実行と呼ばれる高速化手法が適用されており、メモリアクセスが必ずしも命令順に実行されるわけではない。

    • アウトオブオーダ実行をググった

      アウトオブオーダー(Out-of-Order)は、プログラムに記述された命令の順番に関係なく、処理に必要なデータが揃った命令から実行する仕組みです。先の命令処理に必要なデータが揃っていなくても、後の命令処理に必要なデータが揃っていた場合、後の命令から先に実行することができます。

  3. p105 関数ではなくマクロにしている理由は、関数にするとコンパイラによる最適化が行われてしまう可能性があるからである。

    • 口に出して言ってみたいRust玄人セリフTop3に入る

Action

  • バリア同期がどう嬉しいのかわからなかったが、以下参照して納得した

https://doc.rust-lang.org/std/sync/struct.Barrier.html

use std::sync::{Arc, Barrier};
use std::thread;

const MAX_NUM:usize =5;
fn main() {
    let mut v = Vec::new();

    let barrier = Arc::new(Barrier::new(MAX_NUM));

    for i in 0..MAX_NUM {
        let b = barrier.clone();
        let th = thread::spawn(move || {
            println!("before wait {i}");
            b.wait();
            println!("after wait {i}");
        });
        v.push(th);
    }

    for th in v {
        th.join().unwrap();
    }
}

標準出力

before wait 0
before wait 3
before wait 2
before wait 1
before wait 4
after wait 0
after wait 4
after wait 1
after wait 2
after wait 3
`
_5da_5da

p107~

Points

  1. 4.1 デッドロック

    p112 Rustはきわめてよく設計されたプログラミング言語ではあるが、ここで示したような挙動はライフタイムの闇ともいえる。

use std::{sync::{Arc, RwLock, RwLockReadGuard}, thread};

/// 以下のコードはデッドロックとなる場合、ならない場合を示しています
fn main() {
    let val: Arc<RwLock<bool>> = Arc::new(RwLock::new(true));

    let t thread::JoinHandle<()> = thread::spawn(move||{
-        let flag: RwLockReadGuard<bool> = val.read().unwrap();
-        if *flag {
+        let flag: bool = *val.read().unwrap();
+        if flag {
            *val.write().unwrap() = false;
            println!("flag is true");
        }
    });

    t.join().unwrap()
}
  • Lifelime darkness、もっと知りたすぎる...

    • ChatGPTに聞いてみた↓

    Q1. RUSTでデッドロックが発生する件についてライフタイムの闇について教えてください
    →日本語あやしい
    Q2. Rustはコンパイルできれば問題なく動作しますか?

https://chatgpt.com/share/c3b8443f-cabc-417e-b408-18f2a083788e

僕の認識がアップグレードされてよかった...


  1. 4.4 再帰ロック

  • p126のコードを参考に、別のはまりポイントを考えてみた
use std::{sync::{Arc, Mutex, MutexGuard, TryLockError}, thread};

/// `lock()`じゃなくて`try_lock()`を使って書けば冗長だけどハマりずらくなるかもしれない
/// あと、line:2で`try_lock()`使ってるならmatch書いた方が丁寧なのですがdiff見やすくするために省略


fn main(){
    let lock0: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));

    let a: MutexGuard<i32> = lock0.try_lock().unwrap();
    println!("{}",a);
    
-    let b: MutexGuard<i32> = lock0.lock().unwrap();
-    println!("{}",b);
+    let b: Result<MutexGuard<i32>, TryLockError<MutexGuard<i32>>> = lock0.try_lock();
+    match b {
+        Ok(ok)  => println!("{}",ok),
+        Err(err) => println!("{}",err),
+    }
}

  1. 4.5 疑似覚醒

  • rustでのコードサンプルをChatGPTに(ry

https://chatgpt.com/share/35bbc3b7-3709-4fd5-b144-41fe1c7a9a45


use std::{sync::{Arc, Condvar, Mutex, RwLock, RwLockReadGuard}, thread};

fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair_clone = Arc::clone(&pair);

    // スレッド1
    let t1 = thread::spawn(move || {
        let (lock, cvar) = &*pair_clone;
        let mut started = lock.lock().unwrap();
        while !*started {
            println!("Thread 1 waiting...");
            started = cvar.wait(started).unwrap();
        }
        println!("Thread 1 awakened!");
    });

    // スレッド2
    let pair_clone = Arc::clone(&pair);
    let t2 = thread::spawn(move || {
        let (lock, cvar) = &*pair_clone;
        
        let mut started = lock.lock().unwrap();
        *started = true;
        println!("Thread 2 notifying...");
-        cvar.notify_one(); // 通知を送るが、ここで疑似覚醒が発生する可能性がある
+      //cvar.notify_one(); // 通知を送るが、ここで疑似覚醒が発生する可能性がある
        println!("Thread 2 notified...");
    });

    t2.join().unwrap();
    println!("t2 end");
    t1.join().unwrap();    
}

気を付けるべきポイントがいっぱいですね、、

Action

ポイントの中に記載

_5da_5da

p138~178

Points

  1. p155 async/awaitは明示的なFuture型に対する記述と考えるとよく、awaitはFuture型の値が決まるまで処理を停止して他の関数にCPUリソースを譲るために利用し、asyncはFuture型を含む処理を記述するために利用する。

  2. p168 Rustの場合は軽量スレッドなどの高級な言語機能に依存せずにasync/awaitを実現しているため、OSや組み込みソフトウェアなどへの適用が行いやすくなっている。つまり、組み込みソフトウェア、OS、デバイスドライバなどハードウェアに近いソフトウェアを、async/awaitを用いて実装できる可能性を秘めている。

    • さすがLinuxカーネルの開発言語に採用されたRustさん ポテンシャル高い
  3. p171 重要なことなので何度も述べるが、async中にブロッキングを行うようなコードを記述すると、実行速度の劣化やデッドロックが起きてしまう。ブロッキングを行う典型的な関数はスリープ関数である。

    • 読み飛ばしマン、背筋が伸びる

Action

  • ざっと非同期プログラミングの章を読み終えた
    • まだ充分理解できていないけれど、以下の方針でこれからも学習を継続する
      • わからないところがでても次のページを読めばわかる時もある
      • わからないところを質問して1つずつ理解を深める
      • わかるまで繰り返し読む
      • 他の情報も確認する

同期、非同期で書かれたコードを比較
ソース上の違いだけじゃなくて、どう動くのかというところを考えると、
非同期プログラミングの活用シーンが少し理解できた

ブロッキングを避けることで他のタスクを並行して実行できる。
多数のリクエストを効率的に処理するスケーラビリティを向上。
複数の外部サービスへのリクエストを並行して実行し、全体のレスポンス時間を短縮。
UIのあるアプリケーションでの応答性を維持。
特に、Webサーバーやクライアントアプリケーションなど、並行して多くの非同期操作を実行する必要がある場合に非同期コードの利点が顕著になります。