はじめに

最近「Rustがすごい」って言葉をよく聞きます。実際僕も上司に「Rustはいいぞ」って言われてRustに興味を持ちました。
Rustは高速で安全ということがよく言われます。でも実際にどうして高速で安全なのかがいまいちよくわかりません。そこで今回はプログラミング言語が裏でどういった処理をしているかを見つつ、Rustのどういった点が高速で安全なのか考えてみようと思います。

※1/16 例として上げていたコードが動かないものだったため修正しました。ついでに省略していたmain関数やuse文も追加しています

スタック領域とヒープ領域

一般に処理中に値を保持する場所としてスタック領域とヒープ領域というものがあります。Rustはそのうちスタック領域を主に使用しています。

スタック領域は高速に処理ができる一方で、事前に割り当てる領域を決定する(静的な定義)必要があり、融通が効きにくいという面があります。
ヒープ領域はその逆で実行時にその場で領域を割り当てること(動的な定義)ができる代わりに実行は遅くなります。

Rustが他の言語と比べて高速と言われるのはRustがスタック領域を主に使用しているからです。型によって変数のサイズを決定しているためスタック領域を活用することができています。Java, Python, Rubyはヒープ領域を主に使用しているためRustと比べて処理が遅くなってしまいます。Cもスタック領域を活用しているためRustと同じくらい早いですが、Cはヒープ領域を扱うときにmallocとfreeを書く必要があり、管理が難しいです。

ゼロコスト抽象化

Rustの特徴の1つにゼロコスト抽象化というものがあります。これは抽象化したコードをなるべくコンパイルの段階で具体化することによって実行時の処理を軽くするというものです。

抽象化とは?

抽象化は複雑なシステムを分割して個々の処理をひとまとめにすることで全体の内容を把握しやすくすることです。

例えば以下のように元の数字に1を足してそれを出力する処理があったとします。

fn increment_print(mut i: i32) -> i32 {
  i += 1;
  println!("{}", i);
  i
}

この処理を普通に複数書くとコードは冗長になり、何をしているのかがつかみにくくなります

fn main() {
  let mut i = 0;
  i += 1;
  println!("{}", i);
  i += 1;
  println!("{}", i);
  i += 1;
  println!("{}", i);
  println!("{}", i);
}

そこで処理をまとまりとして記述することで何をしているかをわかりやすくするのが抽象化です。
(この例では効果は伝わりにくいですが実際のコードではもっと長い処理を抽象化するので効果がおおきくなります)

fn main() {
  let mut i = 0;
  i = increment_print(i);
  i = increment_print(i);
  i = increment_print(i);
  println!("{}", i);
}

ゼロコスト抽象化とは

通常、抽象化したコードを読み込むときは実行時に抽象化したクラスのコードを参照しにいきます。しかし、その参照の際に時間がかかってしまったり参照結果を保存しようとしてメモリが使われてしまったりしてしまいます。

そこでRustではコンパイル時になるべく読み込みを行うことによって抽象化による速度、メモリ的なコストを最小限にしています。

ただし、この方法には1つ問題があります。抽象化したコードの内容がコンパイル時に判明してない場合読み込みができなくなってしまうのです。

1つ例を考えてみます。

trait Human {
  fn put_gender(&self);
}

struct Man;
struct Woman;

impl Human for Man {
  fn put_gender(&self) {
    println!("male");
  }
}

impl Human for Woman {
  fn put_gender(&self) {
    println!("female");
  }
}

fn main() {
  let human_vec: Vec<Human> = vec![Man, Woman];
  for human in human_vec {
    human.put_gender();
  }
}

このコードではHumanトレイトにMan, Womanという構造体を作っています。
(トレイトはクラスやインターフェースに近いものだと考えてください)
最後にHumanの配列を作り、それぞれでput_genderを読んでいますがこれはうまくいきません。それはコンパイル段階でhuman_vecにある個々のhumanがmanかwomanか判断できていないためです。このような場合、Rustではこのように型を定義する必要があります。

let human_vec: Vec<Box<dyn Human>> = vec![Box::new(Man), Box::new(Woman)];

Boxは先程述べたヒープ領域を使うための宣言です。これによってHumanの中身に関わらずメモリを動的に確保できるようになりました。dynを使うことによって型の内容も動的に決定することが可能になっています。

このようにゼロコスト抽象化では基本的に抽象化による速度の低下などは少なくなり、結果的に処理は高速になりますが、一方で型を動的に決定しなければならないときはdynなどの記述が必要になります。

値の所有権

Rustには所有権という概念があります。この所有権という考え方によってメモリの確保、開放を安全に行うことができるようになっています。

コピーセマンティクスとムーブセマンティクス

プログラミングのコードには処理ごとに様々な意味が込められています。この込められた意味のことをセマンティックスといいます。

一般的なプログラミング言語ではコピーセマンティクスが使われていますが、Rustではムーブセマンティクスが使われています。

a = b

上の式を元にこれらの違いを考えてみます。
コピーセマンティクスでは上の式はbの値がaにコピーされると考えます。
一方、ムーブセマンティクスではbの値の所有権がaに移ったと考えます。

これらは一見結果的に同じことを指してるように思えますが、細かいところで処理が異なってきます。

例えばコピーセマンティクスだと左辺に値をコピーするので等式の実現に元の値の2倍のメモリの確保が必要になりますが、ムーブセマンティクスだと値をそのまま譲渡するのでメモリの確保は元の値の分で十分です。そのためコピーセマンティクスだと値のサイズが大きいときメモリを圧迫することになります。

値の借用

Rustはムーブセマンティクスを使って値の所有権を管理していることがわかりました。ただし、この所有権という考え方を厳格に当てはめようとすると関数の処理が複雑になってしまいます。

例を見てみましょう。

fn b(a: i32) -> i32 {
  println!("{}", a);
  return a;
}

fn main() {
  let mut a = 0;
  a = b(a); // b(a)とはできない
  println!("{}", a);
}

このとき、a = b(a)という処理ではbという関数にaを渡し、その結果をまたaに渡しています。関数にaを渡したときに所有権が移ってしまっているため結果を返してもらうことでまた所有権を取り戻さなければならないのです。これでは毎回関数は値を返すことになり面倒です。

そこで使われるのが借用です。借用は簡単に言うと値のアクセス権(参照といいます)だけを渡す方法です。実際のコードを見てみます。

fn b(a: &i32) {
  println!("{}", a);
}

fn main() {
  let a = 0;
  b(&a); // 所有権を渡していないので値を返す必要がない
  println!("{}", a);
}

b(&a);というのが借用です。&aという部分で値の参照のみを渡しているので関数は値を返す必要がありません。関数の中でaの値を参照して処理を行っています。

借用の場合値のアクセス権をもらうだけなので通常値を変更することはできません。つまり、b(&a)の処理の中でaの値を変更することはできません。しかし、b(mut &a)とすればaの値を変更する処理も書くことができます。(可変な参照)

参照におけるRustの安全性

Rustでは可変な参照は1つしか使うことができず、また、可変な参照と通常の参照を同時に使うことはできません。
このルールがRustの安全性を担保する大きな要素になっています。

このルールがないと複数の可変な参照を作成したときに値が同時に更新されてしまう可能性があり、動かしたときに重大な不具合につながる可能性があります。Rustが安全と言われる所以はこうやってルールをしっかり決めて危険なコードをすべてコンパイル段階でエラーにしているところにあります。これによって実際に動かすコードでは不具合が起こりにくくなっています。(もちろんコンパイルを通すのはその分大変ですが…笑)

スレッド安全性

マルチスレッドでの処理は値の競合などが発生しやすく難しい処理です。Rustはここでも厳密にルールを定めることで実行時にエラーが出にくいようにしています。

通常のマルチスレッド処理

Rustではスレッド処理にはthreadを使います。以下に簡単な例を示します。
(実践Rustプログラミング入門より引用)

use std::thread;

fn main() {
  let mut handles = Vec::new();

  for x in 0..10 {
    handles.push(thread::spawn(move || {
      println!("{}", x);
    }));
  }

  for handle in handles {
    let _ = handle.join();
  }
}

thread::spawnによりブロック内の内容をスレッドで処理します。ここで注意するのは以下の点です。

    • スレッドの終了タイミングは確定しないのでxのメモリが途中で開放されないようmoveで所有権を移す

 

    スレッドの処理が終わる前にプログラムが終了しないようにhandleで終了後のハンドルを受け取り、handle.join()でスレッドの終了を待つ

共有メモリを使うマルチスレッド処理

次にマルチスレッド内で共通の値を扱う場合を考えます。通常Rustでは、所有権が厳しくチェックされるため共通の値を扱うことができずコンパイルエラーになります。それを乗り越えて扱えるようにするにはRc, Arcといった型を使う必要があります。また、値の更新を含む場合には別途排他制御を行うMutexという型を使う必要があります。

それぞれ
Rc ・・・シングルスレッド
Arc ・・・マルチスレッド
Arc + Mutex ・・・値の更新を含むマルチスレッド
というように使い分けられています。

Mutexにはlockという関数があり、これを使うことで値へのアクセスが常に1つのスレッドからしか行われていないことを保証することができます。

ArcとMutexを使った場合のコードは以下のようになります。
(実践Rustプログラミング入門より引用)

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

fn main() {
  let mut handles = Vec::new();
  let data = Arc::new(Mutex::new(vec![1; 10]));

  for x in 0..10 {
    let data_ref = data.clone();
    handles.push(thread::spawn(move || {
      let mut data = data_ref.lock().unwrap();
      data[x] += 1;
    }));
  }

  for handle in handles {
    let _ = handle.join();
  }

  dbg!(data);
}

data.clone()により値の所有権を得ているスレッドの数を管理します。その後はlock()により値を更新するスレッドを1つに制限しています。

データ通信を伴うマルチスレッド処理

最後にスレッド間でデータを転送する場合を考えます。この場合はmpsc::channel()によって送信、受信用のインスタンスを作成しそれによって通信を行います。

use std::sync::mpsc;
use std::thread;

fn main() {
  let (tx, rx) = mpsc::channel();
  let handle = thread::spawn(move || {
    let data = rx.recv().unwrap();
    println!("{}", data);
  });

  let _ = tx.send("data");
  let _ = handle.join();
}

tx.send()によってデータを送り、rx.recv()によってデータを取得しています。

トレイトによるスレッド安全性

ここまででスレッドの処理の方法とその際の制約について見てきて、Rustは処理に厳しく制約がかかっていることにより実行時の安全性が担保されるということがわかりましたが、実はこれ以外にもトレイトによる制約があります。

実はトレイトにはSendやSyncというものがあり、これらを実装していない型を転送、共有しようとするとコンパイルエラーになります。これがトレイトによる制約です。これによりスレッドを利用するときは下手にSendやSyncを実装しようとしない限りちゃんとコンパイル時に安全性を判断してもらうことができます。

非同期処理

最後に非同期処理について見ていきます。非同期処理はFutureというトレイトによって実装され、asyncによって制御され、awaitによって値が受け取られます。

FutureトレイトにはPollというメソッドがあることが保証されています。このPollは処理の結果または実行中であることを示す値Pendingを返します。つまりFutureによって処理が終わっていればその結果を、終わってなければPengingを返すことができます。これは非同期処理でやりたいことそのものです。

このFutureを扱うにはasyncを使います。asyncはFutureトレイトを実装した型を返す処理を表現します。

asyncはブロックとして用いる場合と関数の頭で宣言する場合の2通りの表現があります。以下の2つの例は同じ内容を表しています。

async fn async_method(a: i32) -> i32 {
  ...
}
fn async_method(a: i32) -> impl Future<Output = i32> {
  async {
    ...
  }
}

async構文の中で結果が返ってくるのを待つ場合はawaitを使います。このawaitは非同期処理の結果を受け取るためのものなのでasync構文の外で書くとコンパイルエラーになります。

非同期処理ではこのようにFutureで実行中か完了しているかを判定できるようにし、asyncで非同期処理の範囲を明確にし、awaitで値の取得タイミングを明確にしています。そして、この形を外れるとコンパイルエラーになるので処理が複雑になっても処理でミスが発生することを未然に防いでくれています。

まとめ

Rustは厳密な型定義によりスタック領域を使用することで高速性を実現し、それぞれの処理でクレイトを元にした厳格なルールを定めることでミスをコンパイル段階で弾けるようになり安全性を実現しています。このRustの特徴は人によっては制約が多いため不自由に感じるかもしれませんが、複雑な処理を記述するときなどは事前にエラーを検知してくれるので非常に心強い味方になるかもしれません。

おわりに

みんなもRust使いましょう!

参考図書、参考ページ

実践Rustプログラミング入門
メモリとスタックとヒープとプログラミング言語
C++ ムーブセマンティクスと右辺値の概念を初心者向けに

广告
将在 10 秒后关闭
bannerAds