1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Rust 100 Ex 🏃【27/37】 スレッド・'staticライフタイム ~並列処理に見るRustの恩恵~

Last updated at Posted at 2024-07-17

前の記事

全記事一覧

100 Exercise To Learn Rust 演習第27回になります、今回から新章7章、スレッドの話題です!

今回の関連ページ

[07_threads/01_threads] スレッド(並列処理)

問題はこちらです。

lib.rs
// TODO: implement a multi-threaded version of the `sum` function
//  using `spawn` and `join`.
//  Given a vector of integers, split the vector into two halves and
//  sum each half in a separate thread.

// Caveat: We can't test *how* the function is implemented,
// we can only verify that it produces the correct result.
// You _could_ pass this test by just returning `v.iter().sum()`,
// but that would defeat the purpose of the exercise.
//
// Hint: you won't be able to get the spawned threads to _borrow_
// slices of the vector directly. You'll need to allocate new
// vectors for each half of the original vector. We'll see why
// this is necessary in the next exercise.
use std::thread;

pub fn sum(v: Vec<i32>) -> i32 {
    todo!()
}

TODO部分を要約すると次のような感じです。

  • TODO: thread::spanwJoinHandle::join を使用する形で sum 関数のマルチスレッド版を作成してください

  • 整数のベクタが渡されるので、これらを2つに分けて合計を計算した後、その2つを足し合わせるようにします

  • 警告: 調べるすべがないのでスレッド使わないで普通に足し合わせてもパスできるけど、それだと演習の意味がないからやめてね

  • ヒント: ベクトルをスライスにして参照をスレッドに渡すことはできません。この件については次の問題(本記事の2問目)の範囲です

    • (とりあえず、)新しいベクトルを作成しましょう
テストを含めた全体
lib.rs
// TODO: implement a multi-threaded version of the `sum` function
//  using `spawn` and `join`.
//  Given a vector of integers, split the vector into two halves and
//  sum each half in a separate thread.

// Caveat: We can't test *how* the function is implemented,
// we can only verify that it produces the correct result.
// You _could_ pass this test by just returning `v.iter().sum()`,
// but that would defeat the purpose of the exercise.
//
// Hint: you won't be able to get the spawned threads to _borrow_
// slices of the vector directly. You'll need to allocate new
// vectors for each half of the original vector. We'll see why
// this is necessary in the next exercise.
use std::thread;

pub fn sum(v: Vec<i32>) -> i32 {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty() {
        assert_eq!(sum(vec![]), 0);
    }

    #[test]
    fn one() {
        assert_eq!(sum(vec![1]), 1);
    }

    #[test]
    fn five() {
        assert_eq!(sum(vec![1, 2, 3, 4, 5]), 15);
    }

    #[test]
    fn nine() {
        assert_eq!(sum(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]), 45);
    }

    #[test]
    fn ten() {
        assert_eq!(sum(vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), 55);
    }
}

「2つのスレッドを建てて合計計算を並行処理して最後に足し合わせて」という問題ですね。

解説

thread::spawn にてスレッドを生成し、 t1.join() でスレッド内の処理が完了するのを待ち、最後にスレッドが返してきた値を足し合わせます。

lib.rs (コンパイルエラー!)
pub fn sum(v: Vec<i32>) -> i32 {
    let (v1, v2) = v.split_at(v.len() / 2);

    let t1 = thread::spawn(|| v1.iter().sum::<i32>());
    let t2 = thread::spawn(|| v2.iter().sum::<i32>());

    let res1 = t1.join().unwrap();
    let res2 = t2.join().unwrap();

    res1 + res2
}

スライスの split_at 等を使って配列を分割すると、元の配列への参照になってしまいコンパイルエラーになります。

エラー内容
   Compiling threads v0.1.0 (/path/to/100-exercises-to-learn-rust/exercises/07_threads/01_threads)
error[E0597]: `v` does not live long enough
   --> exercises/07_threads/01_threads/src/lib.rs:104:20
    |
99  | pub fn sum(v: Vec<i32>) -> i32 {
    |            - binding `v` declared here
...
104 |     let (v1, v2) = v.split_at(v.len() / 2);
    |                    ^ borrowed value does not live long enough
105 |
106 |     let t1 = thread::spawn(|| v1.iter().sum::<i32>());
    |              ---------------------------------------- argument requires that `v` is borrowed for `'static`
...
113 | }
    | - `v` dropped here while still borrowed

For more information about this error, try `rustc --explain E0597`.
error: could not compile `threads` (lib test) due to 1 previous error

 *  The terminal process "cargo 'test', '--package', 'threads', '--lib', '--', 'tests', '--show-output'" failed to launch (exit code: 101). 
 *  Terminal will be reused by tasks, press any key to close it. 

祈手アンブラハンズ「あの方は今はいない・・・・・...スレッドを跨ぐやり方は不都合が出やすい・・・・・・・・・・・・・・・

スレッドもそして次章8章の非同期処理にも登場する基本的な考えの一つとして、「参照類の 'static ライフタイムではない値はそのままだとスレッドを跨げない」というものがあります。次の問題の解説で改めて紹介したいと思います。

とりあえず、新しく所有権を握れる構造体を作り、参照ではなく実体を渡すようにしてこの問題を回避します。 split_off メソッドdrain メソッド を使うといい感じに書けます。

lib.rs
pub fn sum(mut v: Vec<i32>) -> i32 {
    // let v2 = v.split_off(v.len() / 2);
    // Clone トレイトを要求しないバージョン↓
    let v2: Vec<i32> = v.drain((v.len() / 2)..).collect();

    let t1 = thread::spawn(move || v.iter().sum::<i32>());
    let t2 = thread::spawn(move || v2.iter().sum::<i32>());

    let res1 = t1.join().unwrap();
    let res2 = t2.join().unwrap();

    res1 + res2
}

地味に move キーワードが登場していることにも注目。クロージャはデフォルトだと変数をなるべく「参照で」キャプチャしてこようとする(この参照は当然 'static ではない)ので、「実体を」キャプチャさせるようにするためにつけるキーワードです。7章8章ではクロージャに基本的に付与することになるでしょう。

余談、ちょっとカッコつけたDRYな感じの回答です。全部イテレータでおk!(行数増えてる気がするのは気の所為)

lib.rs
pub fn _sum(mut v: Vec<i32>) -> i32 {
    let v2 = v.drain((v.len() / 2)..).collect();

    vec![v, v2]
        .into_iter()
        .map(|v| thread::spawn(move || v.iter().sum::<i32>()))
        .collect::<Vec<_>>()
        .into_iter()
        .map(|handle| handle.join().unwrap())
        .sum()
}

どちらかと言えばイテレータにまつわる注意点ですが、一旦途中で .collect::<Vec<_>>() を挟んで、イテレータ内の全ての thread::spawn が実行された上で後の .map(|handle| handle.join().unwrap()) が呼ばれるようにしなければなりません。

もし次のように書いたとしても、

lib.rs
use std::thread;

pub fn sum(mut v: Vec<i32>) -> i32 {
    let v2 = v.drain((v.len() / 2)..).collect();

    vec![v, v2]
        .into_iter()
        .map(|v| thread::spawn(move || v.iter().sum::<i32>()))
        .map(|handle| handle.join().unwrap())
        .sum()
}

これだとイテレータの特性より thread::spawn 後にすぐそのハンドルについて handle.join() が呼ばれてしまい、直列に実行されてしまいます。気をつけましょう! こんな書き方する人自体が少数派かもですが...

[07_threads/02_static] 'static ライフタイム

問題はこちらです。

lib.rs
// TODO: Given a static slice of integers, split the slice into two halves and
//  sum each half in a separate thread.
//  Do not allocate any additional memory!
use std::thread;

pub fn sum(slice: &'static [i32]) -> i32 {
    todo!()
}

さっきの問題では引数として Vec<i32> 実体が渡されましたが、今回は「staticな実体への参照(これもまたstatic)」を引数として受け取り、同様に分割して足し合わせる並列処理を書く問題です。

今回は先ほどとは異なり 新しいメモリアロケーションが起きないようにせよ という制約が加えられています。つまり、先程の回答の使い回しはしないでねということです。

テストを含めた全体
lib.rs
// TODO: Given a static slice of integers, split the slice into two halves and
//  sum each half in a separate thread.
//  Do not allocate any additional memory!
use std::thread;

pub fn sum(slice: &'static [i32]) -> i32 {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty() {
        static ARRAY: [i32; 0] = [];
        assert_eq!(sum(&ARRAY), 0);
    }

    #[test]
    fn one() {
        static ARRAY: [i32; 1] = [1];
        assert_eq!(sum(&ARRAY), 1);
    }

    #[test]
    fn five() {
        static ARRAY: [i32; 5] = [1, 2, 3, 4, 5];
        assert_eq!(sum(&ARRAY), 15);
    }

    #[test]
    fn nine() {
        static ARRAY: [i32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 9];
        assert_eq!(sum(&ARRAY), 45);
    }

    #[test]
    fn ten() {
        static ARRAY: [i32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        assert_eq!(sum(&ARRAY), 55);
    }
}

解説

今度は逆に split_at を使い、ヒープへのメモリアロケーションをさせない回答になります!

lib.rs
use std::thread;

pub fn sum(slice: &'static [i32]) -> i32 {
    let (s1, s2) = slice.split_at(slice.len() / 2);

    vec![s1, s2]
        .into_iter()
        .map(|s| thread::spawn(move || s.iter().sum::<i32>()))
        .collect::<Vec<_>>()
        .into_iter()
        .map(|handle| handle.join().unwrap())
        .sum()
}

split_at メソッド返り値の (&[T], &[T]) のライフタイムは、引数 &self のライフタイムと同じになるので、今回は各スレッドに 'static ライフタイムな値が行き、よってコンパイルエラーにはならず合計を計算できます!

そういえば 'static ライフタイムでない値だとスレッドを跨げない理由の解説がまだでした。 (次回以降扱うスコープ付きスレッド等を除き) ずばり、 スレッドそれ自体は「いつまで存在しているか」わからないため です。いつ join が呼ばれるかはわからないので、スレッドがいつまで生きているかはわかりません! 'static ではないと次の図のような問題が起きる可能性があります。

スレッド間でライフタイムを共有できるような術1を特に使わない場合、 'static ライフタイムな値しかクロージャに含められないという制約を置くことでとりあえずスレッド間の値の受け渡しを問題ないものとしているわけです。ライフタイムといい感じに付き合いながらスレッドを扱う術は次回以降の話題です!

'static ライフタイムは実体と参照で異なる意味を持つ」のがややこしい点ですが、これについて Book の解説が今回も秀逸なので最後の方を是非読んでみてほしいです。

すなわち 'static ライフタイムは次のように捉えることができます:

  • 所有権ごと値をください!
  • プログラムが動いている間ずっと存在する参照(&'static T)をください!

実体と参照で意味が異なるとは言いますが、「ずっと存在『できる』のが 'static ライフタイム」と捉えると上記2つの捉え方をカバーできて直感的なんじゃないかと思います。

「ずっとは存在できない」ケースは、言い換えると「(いつ無効になるかわからない)他のリソースへの依存を内包している」ということであり、 'static じゃない構造体(この後登場する MutexGuard<'_, T> とか)は大体このパターンです。参照それ自体なら「参照先はプログラムが動いている間ずっと存在」していれば存在できます。そして通常の構造体は「他のリソースを参照していない」か「依存している他のリソースもずっと存在」していれば存在できます。この辺を総括した名前として見ると、「 'static ライフタイム」という名称は結構しっくり来るのではないでしょうか?

ところで、Rustが難しいから並列処理においてこんなライフタイムの話をしなければならないのでしょうか...?筆者は逆だと考えています。「並列処理自体が本来リソースのライフタイムにまで配慮するべき」なのです。他の言語でこの辺の話を曖昧にして処理を書くと「ガッ!」「ガッ!」「ガッ!」の連続になることは想像に難くないです。 Rustはライフタイムというツールのお陰で並列処理で気をつけるべき点を簡単に説明できているというわけです!故に、個人的には、 並列処理や非同期処理の導入はRustの学習コストが他言語のそれと逆転する瞬間 なんじゃないかと思っています!逆に他言語話者はこの辺どうしてるんだ...

LazyCellLazyLock

static 周りの話題としてタイムリーだったので取り上げたいと思います。 LazyCellLazyLock は先日出た Rust 1.80.0 にて安定化した機能で、共に「初めて参照された時にクロージャの中身を実行し、得られた値の参照を返す」という構造体です!

特に LazyLock の方は static を用いた変数宣言と共に用いられることで「最初から必要というわけではないけど、初めて参照された際に初期化されてくれるとありがたい」リソースや、「(コンパイル時には計算できないものの、)プログラム全体を通して有効な値とその参照を static に持ち続けたい」というようなケースで特に効率化に寄与します!(やりたいことの説明としてはこの記事様がわかりやすいです)

一番わかりやすい例だと正規表現構造体 Regex の生成処理を一度で済ませる、等でしょうか。

Rust
use std::sync::LazyLock;
use regex::Regex;

static MAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r#"^[^\s@]+@[^\s@]+\.[^\s@\.]+$"#).unwrap()
});

fn main() {
    assert!(MAIL_REGEX.is_match("hoge+fuga@gmail.com"));
    assert!(!MAIL_REGEX.is_match("Invalid Mail Address."));
}

LazyCellLazyLock の違いはスレッドセーフかどうかで、本章以降でこの辺も詳しく解説されますが、スレッドを跨いで使用する場合は Sync が実装された LazyLock の方を使います。また今回例を作ってわかりましたが、 static で使いたい場合スレッドを跨がさる2ので同様の理由で LazyLock を使う必要があるみたいです!

スレッド関連初回ということで結構話しすぎました...では次の問題に行きましょう!

次の記事: 【28】 リーク・スコープ付きスレッド ~ライフタイムに技あり!~

  1. 直接的にライフタイムを知らせ合うような仕組みはない(次回のスコープ付きスレッドはある意味これ...?)ですが、まぁ要は今回みたいな問題を回避できる他の方法、ぐらいの意味です。

  2. 「〇〇さる」や「〇〇ささる」という東北・北海道弁の表現です。主語・主体の意志に反して勝手にそのような状態になってしまう時に使える便利な文法です(例: 「勝手に押ささる嫌な広告UIだ」)。広まれ~

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?