大胆不敵な保護。 Rustのスレッドセーフティ

これは、フィアレスプロテクションシリーズの第2部です。 最初にメモリセキュリティについて話しました



最新のアプリケーションはマルチスレッドです。タスクは、タスクを連続して実行するのではなく、スレッドを使用して複数のタスクを同時に実行します。 私たちは毎日、 同時作業並行性を毎日観察ています。





並列ストリームは作業を高速化しますが、同期の問題、つまりデッドロックと競合状態のセットをもたらします。 セキュリティの観点から、なぜスレッドの安全性に関心があるのですか? メモリとスレッドのセキュリティには、同じ主な問題が1つあります。それは、リソースの不適切な使用です。 ここでの攻撃には、特権の昇格、任意のコード実行(ACE)、セキュリティチェックのバイパスなど、メモリ攻撃と同じ効果があります。



実装エラーなどの同時実行エラーは、プログラムの正確性と密接に関連しています。 メモリの脆弱性はほぼ常に危険ですが、実装/論理エラーは、セキュリティ契約の遵守に関連するコードの一部で発生しない場合、セキュリティの問題を必ずしも示しません(たとえば、セキュリティチェックをバイパスする許可)。 しかし、並行性のバグには特異性があります。 論理エラーに起因するセキュリティ問題が対応するコードの隣に頻繁に現れる場合、同時エラーは他の機能で発生することが多く、エラーが直接発生した機能では発生せず、エラーの追跡と排除が困難になります。 別の難点は、不適切なメモリ処理と同時実行エラーの間の特定のオーバーラップです。これは、データの競合で見られます。



プログラミング言語は、開発者がマルチスレッドアプリケーションのパフォーマンスとセキュリティの問題を管理するのに役立つさまざまな並行性戦略を開発しました。



並行性の問題



パラレルプログラミングは通常よりも難しいことが一般に受け入れられています。脳は逐次推論に適しています。 並列コードは、デッドロック、競合、データの競合など、スレッド間で予期しない望ましくない相互作用を引き起こす可能性があります。



デッドロックは、複数のスレッドが相互に特定のアクションを実行して作業を継続することを期待する場合に発生します。 この望ましくない動作は、サービス拒否攻撃を引き起こす可能性がありますが、ACEなどの脆弱性を引き起こすことはありません。



競合状態は、タスクの時間または順序がプログラムの正確さに影響を与える可能性がある状況です。 複数のスレッドが少なくとも1回の書き込み試行で同じメモリ位置に同時にアクセスしようとすると、データの競合が発生します。 競合状態とデータ競合が互いに独立して発生することがあります 。 しかし、 データの競合は常に危険です。



同時実行エラーの潜在的な結果



  1. デッドロック

  2. 情報の損失:別のスレッドが情報を上書きします

  3. 整合性の損失:複数のストリームからの情報が織り交ぜられています

  4. 存続可能性:共有リソースへの不均一なアクセスによるパフォーマンスの問題


最も有名なタイプの同時実行攻撃は、 TOCTOU (チェックから使用までの時間)と呼ばれます。実際、レースの状態は、条件(セキュリティ資格情報など)のチェックと結果の使用の間です。 TOCTOU攻撃により、整合性が失われます。



相互ロックと存続可能性の損失は、セキュリティの問題ではなくパフォーマンスの問題と見なされますが、情報の損失と整合性の損失はセキュリティに関連する可能性があります。 Red Balloon Securityの記事では、考えられるエクスプロイトの一部を取り上げています。 1つの例は、ポインターの破損とそれに続く特権の昇格またはリモートコードの実行です。 このエクスプロイトでは、共有ELFライブラリ(実行可能およびリンク可能形式)をロードする関数は、最初の呼び出しでのみセマフォを正しく開始し、スレッドの数を誤って制限するため、カーネルメモリが破損します。 この攻撃は、情報損失の例です。



同時実行エラーの再現が難しいため、並行プログラミングの最も難しい部分はテストとデバッグです。 イベントのタイミング、オペレーティングシステムの決定、ネットワークトラフィック、その他の要因...これらすべてが、各起動時のプログラムの動作を変更します。









バグを探すよりもプログラム全体を削除するほうが簡単な場合があります。 ハイゼンバグ



動作は開始するたびに変化するだけでなく、出力の挿入や演算子のデバッグでさえ動作を変更する可能性があり、その結果、「ハイゼンベルグバグ」(並列プログラミングに典型的な非決定性の再現困難なエラー)が発生し、不可解に消えます。



並列プログラミングは困難です。 並列コードが他の並列コードとどのように相互作用するかを予測することは困難です。 エラーが表示されると、それらを見つけて修正することは困難です。 テスターに​​頼る代わりに、プログラムを開発する方法と、並列コードの作成を容易にする言語の使用を見てみましょう。



まず、「スレッドセーフ」の概念を定式化します。



「データ型または静的メソッドは、これらのスレッドの実行方法に関係なく、複数のスレッドから呼び出されたときに正しく動作し、呼び出し元のコードからの追加調整を必要としない場合、スレッドセーフと見なされます。」 MIT


プログラミング言語と並行性の仕組み



静的スレッドセーフのない言語では、プログラマは、別のスレッドと共有され、いつでも変更できるメモリを常に監視する必要があります。 シーケンシャルプログラミングでは、コードの別の部分が静かにそれらを変更する場合、グローバル変数を避けるように教えられています。 手動のメモリ管理と同様に、一般的なデータの安全な変更を保証することをプログラマに要求することは不可能です。









「絶え間ない警戒!」



通常、プログラミング言語は2つのアプローチに制限されています。



  1. 可変性の制限または共有アクセスの制限

  2. 手動のスレッドセーフ(ロック、セマフォなど)


スレッド制限のある言語では、可変変数に1スレッドの制限を設けるか、すべての共有変数が不変であることを要求します。 どちらのアプローチも、データ競合の基本的な問題(共有データの誤った変更)に対処しますが、制限は厳しすぎます。 この問題を解決するために、ミューテックスなどの低レベルの同期プリミティブが言語で作成されました。 これらを使用して、スレッドセーフなデータ構造を構築できます。



Pythonおよびインタープリターによるグローバルロック



PythonおよびCpythonのリファレンス実装には、グローバルインタープリターロック(GIL)と呼ばれる一種のミューテックスがあり、1つのスレッドがオブジェクトにアクセスするときに他のすべてのスレッドをブロックします。 マルチスレッドPythonは、GILレイテンシによる非効率性で有名です。 したがって、ほとんどの並行Pythonプログラムはいくつかのプロセスで動作するため、それぞれに独自のGILがあります。



Javaおよびランタイムの例外



Javaは、共有メモリモデルによる同時プログラミングをサポートしています。 各スレッドには独自の実行パスがありますが、プログラム内の任意のオブジェクトにアクセスできます。プログラマは、組み込みJavaプリミティブを使用してスレッド間のアクセスを同期する必要があります。



Javaにはスレッドセーフなプログラムを作成するためのビルディングブロックがありますが、 スレッドの安全性はコンパイラによって保証されません (メモリセキュリティとは対照的に)。 非同期のメモリアクセスが発生すると(つまり、データの競合)、Javaはランタイム例外をスローしますが、プログラマは同時実行プリミティブを正しく使用する必要があります。



C ++とプログラマの頭脳



PythonはGILで競合状態を回避し、Javaは実行時に例外をスローしますが、C ++はプログラマがメモリアクセスを手動で同期することを期待しています。 C ++ 11より前は、標準ライブラリには同時実行プリミティブが含まれていませんでした



ほとんどの言語には、スレッドセーフコードを記述するためのツールが用意されており、データの競合と競合状態を検出するための特別な方法があります。 ただし、スレッドの安全性を保証するものではなく、データの競合を防ぎません。



Rustの問題を解決するには?



Rustは、オーナーシップルールとセーフタイプを使用して、コンパイル時に競合状態から完全に保護する、競合状態を排除するための多面的なアプローチを採用しています。



最初の記事では、所有権の概念を紹介しました。これはRustの基本概念の1つです。 各変数には一意の所有者がいて、所有権を譲渡または借用できます。 別のスレッドがリソースを変更する場合は、変数を新しいスレッドに移動して所有権を移行します。



移動すると例外がスローされます。複数のスレッドが同じメモリに書き込むことはできますが、同時に書き込むことはできません。 所有者は常に一人なので、他のスレッドが変数を受け取るとどうなりますか?



Rustでは、1つまたは複数の不変のものを使用できます。 可変および不変の借入(または複数の可変借入)を同時に導入することはできません。 メモリセキュリティでは、リソースを適切に解放することが重要です。スレッドセーフでは、常に1つのスレッドのみが変数を変更する権利を持っていることが重要です。 また、このような状況では、他のフローが古い借入を参照することはありません。記録または共有は可能ですが、両方はできません。



所有権の概念は、メモリの脆弱性に対処するように設計されています。 また、データの競合も防ぐことがわかりました。



多くの言語にはメモリセキュリティメソッド(リンクカウントやガベージコレクションなど)がありますが、通常はデータの競合を防ぐために手動同期または同時共有の禁止に依存しています。 Rustアプローチは両方のタイプのセキュリティに対処し、リソースの許容可能な使用を決定し、コンパイル時にこの有効性を確保するという主な問題を解決しようとします。











しかし、待ってください! それだけではありません!



所有権の規則では、複数のスレッドが同じメモリ位置にデータを書き込むことを許可せず、スレッド間の同時データ交換と可変性を禁止しますが、これは必ずしもスレッドセーフなデータ構造を提供しません。 Rustの各データ構造は、スレッドセーフかどうかのいずれかです。 これは、型システムを使用してコンパイラに渡されます。



「適切に型付けされたプログラムは間違いを起こすことはできません。」 -ロビン・ミルナー、1978


プログラミング言語では、型システムは許容可能な動作を記述します。 言い換えれば、適切に型付けされたプログラムは明確に定義されています。 型が意図した意味を捉えるのに十分な表現力を持っている限り、適切に型付けされたプログラムは意図したとおりに動作します。



Rustはタイプセーフな言語です。ここでは、コンパイラはすべてのタイプの一貫性をチェックします。 たとえば、次のコードはコンパイルされません。



let mut x = "I am a string"; x = 6;
      
      





  error[E0308]: mismatched types --> src/main.rs:6:5 | 6 | x = 6; // | ^ expected &str, found integral variable | = note: expected type `&str` found type `{integer}`
      
      





Rustのすべての変数は、多くの場合暗黙的です。 また、特性システムを使用して新しいタイプを定義し、各タイプの機能を説明することもできます。 特性は、インターフェースの抽象化を提供します。 2つの重要な組み込み特性はSend



およびSync



。これらは、デフォルトで各タイプのコンパイラーによって提供されます。





以下の例は、スレッドを生成する標準ライブラリのコードの簡略版です。



  fn spawn<Closure: Fn() + Send>(closure: Closure){ ... } let x = std::rc::Rc::new(6); spawn(|| { x; });
      
      





spawn



関数は単一のclosure



引数を取り、 Send



およびFn



特性を実装する後者のタイプを必要としFn



。 ストリームを作成し、変数x



closure



値を渡そうとするとx



コンパイラーはエラーをスローします。



 エラー[E0277]: `std :: rc :: Rc <i32>`はスレッド間で安全に送信できません
      -> src / main.rs:8:1
       |
     8 |  spawn(move || {x;});
       |  ^^^^^ `std :: rc :: Rc <i32>`はスレッド間で安全に送信できません
       |
       =ヘルプ: `[closure@src/main.rs:8:7:8:21 x:std :: rc :: Rc <i32>]`内では、特性 `std :: marker :: Send`は実装されていません`std :: rc :: Rc <i32>`の場合
       =注:タイプ[[closure@src/main.rs:8:7:8:21 x:std :: rc :: Rc <i32>]]内に表示されるため、必須です
    注: `spawn`で必要 


Send



およびSync



特性により、Rustタイプシステムは共有できるデータを理解できます。 この情報を型システムに含めることにより、スレッドセーフは型セーフの一部になります。 ドキュメントの代わりに、 スレッドの安全性はコンパイラーの法律によって実装されています。



プログラマーはスレッド間で共通のオブジェクトを明確に認識し、コンパイラーはこのインストールの信頼性を保証します。











並列プログラミングツールは多くの言語で利用できますが、競合状態を防ぐことは簡単ではありません。 プログラマーが複雑に命令を変更し、スレッド間でやり取りする必要がある場合、エラーは避けられません。 スレッドとメモリのセキュリティ違反は同様の影響を及ぼしますが、リンクカウントやガベージコレクションなどの従来のメモリ保護ツールは競合状態を防止しません。 メモリセキュリティの静的な保証に加えて、Rust所有権モデルは、スレッド間での安全でないデータの変更やオブジェクトの誤った共有も防ぎますが、型システムはコンパイル時にスレッドセーフを提供します。










All Articles