Rust ネットワーク プログラミングと安全でないモジュール

ネットワーク層では、IPv4 と IPv6 は現在相互に競合しており、IPv6 は IPv4 を完全に置き換えたわけではありません。トランスポート層では、遅延に非常に敏感なアプリケーション (ゲームのクイック プロトコルなど) を除いて、ほとんどのアプリケーションが TCP を使用します。一方、アプリケーション層では、ユーザーフレンドリーであり、ファイアウォールに優しい HTTP プロトコル ファミリ: HTTP、WebSocket、HTTP/2、およびまだドラフト段階の HTTP/3 は、長い進化の中で際立って主流になりました。アプリケーションの選択。
Rust 標準ライブラリは、TCP/IP プロトコル スタック全体を使用するためのカプセル化を提供するstd::netを提供します。ただし、std::net は同期なので、高性能な非同期ネットワークを構築したい場合は tokio を使用できます。tokio::net はstd::net とほぼ同じカプセル化を提供するため、std::net に慣れると、tokio::net の機能もよく理解できるようになります。それでは、std::net から始めましょう。
同期メソッド
TcpListener/TcpStream TCP サーバーを作成する場合は、TcpListener を使用してポートをバインドし、ループを使用して受信したクライアント要求を処理できます。リクエストを受信すると、TcpStream を取得します。これは読み取り/書き込み特性を実装し、ファイルの読み取りと書き込みと同じようにソケットの読み取りと書き込みを行うことができます。


use std::{
    
    
    io::{
    
    Read, Write},
    net::TcpListener,
    thread,
};

fn main() {
    
    
    let listener = TcpListener::bind("0.0.0.0:9527").unwrap();
    loop {
    
    
        let (mut stream, addr) = listener.accept().unwrap();
        println!("Accepted a new connection: {}", addr);
        thread::spawn(move || {
    
    
            let mut buf = [0u8; 12];
            stream.read_exact(&mut buf).unwrap();
            println!("data: {:?}", String::from_utf8_lossy(&buf));
            // 一共写了 17 个字节
            stream.write_all(b"glad to meet you!").unwrap();
        });
    }
}

クライアントの場合、TcpStream::connect() を使用して TcpStream を取得できます。クライアントのリクエストがサーバーによって受け入れられると、データを送信または受信できるようになります。


use std::{
    
    
    io::{
    
    Read, Write},
    net::TcpStream,
};

fn main() {
    
    
    let mut stream = TcpStream::connect("127.0.0.1:9527").unwrap();
    // 一共写了 12 个字节
    stream.write_all(b"hello world!").unwrap();

    let mut buf = [0u8; 17];
    stream.read_exact(&mut buf).unwrap();
    println!("data: {:?}", String::from_utf8_lossy(&buf));
}

(ここでの欠点の 1 つは、クライアントとサーバーの両方が受信するデータのサイズをハードコードする必要があることですが、これは柔軟性が十分ではありません。メッセージ フレームを使用してこれをより適切に処理する方法については、後ほど説明します。)

クライアント コードからわかるように、TcpStream を明示的に閉じる必要はありません。これは、TcpStream の内部実装が Drop 特性も処理するため、スコープを出るときに閉じられるからです。

メイン スレッドが新しいリンクを処理し、接続を処理するプロセスは、メイン ループで直接処理するのではなく、別のスレッドまたは別の非同期タスクで実行する必要があることがわかります。これにより、メイン ループがブロックされ、現在の接続の前に新しい接続を受け入れることはできません()。これは以前のプロジェクトと一致しています。

ただし、頻繁に接続と終了を繰り返すネットワーク接続をスレッドで処理すると効率の問題が発生し、次に、スレッド間で共通のデータをどのように共有するかという問題も発生します。

スレッドが継続的に作成されると、接続数が多くなると、システムで利用可能なすべてのスレッド リソースが簡単に消費されてしまいます。さらに、スレッドのスケジューリングはオペレーティング システムによって完了されるため、各スケジューリングは複雑で効率の低い保存と読み込みのコンテキスト切り替えプロセスを経る必要があるため、スレッドを使用すると、C10K のボトルネックが発生します。接続数が数万のレベルに達すると、システムはリソースとコンピューティング能力** の二重のボトルネックに遭遇します。リソースの観点から見ると、スレッドが多すぎると多くのメモリが占​​有されます。Rust のデフォルトのスタック サイズは 2M で、10,000 個の接続は 20G のメモリを占有します (もちろん、デフォルトのスタック サイズは必要に応じて変更できます)。計算能力が不足すると、スレッドが多すぎると、接続データの到着時にスレッドが前後に切り替わり、CPU がビジー状態になり、それ以上の接続要求を処理できなくなります。**
スレッド プールは、スレッドが頻繁に作成および破棄されるのを防ぐと考えられますが、その効果は明ら​​かではない場合があります。C10K のボトルネックを突破して C10M に到達したい場合は、処理にユーザー モードのコルーチン (Erlang/Golang のようなスタックフル コルーチン、または Rust の非同期処理のようなノンストップ
コルーチン) のみを使用できますしたがって、Rust のほとんどのネットワーク関連コードでは、処理に std::net を直接使用するものはほとんどなく、ほとんどのコードが tokio などの非同期ネットワーク ランタイムを使用していることがわかります。ここで前回のものと比較してみましょう

共有された情報はどうなるのでしょうか?
2 つの質問: サーバーを構築するとき、データベース接続など、使用するすべての接続に対して何らかの共有状態が常に存在します。このようなシナリオでは、共有データを変更する必要がない場合は Arc の使用を検討でき、変更する必要がある場合は Arc> を使用できます。

ただし、ロックを使用するということは、ロックされたリソースにクリティカル パス上でアクセスする必要があると、システム全体のスループットに大きな影響を与えることを意味します。
考え方の 1 つは、競合を減らすためにロックの粒度を下げることです。たとえば、kv サーバーでは、キーを法 N でハッシュし、異なるキーを N 個のメモリ ストアに割り当てます。このようにして、ロックの粒度は前の 1/N に減ります。

もう 1 つのアイデアは、共有リソースのアクセス方法を変更して、特定のスレッドのみがアクセスできるようにし、他のスレッドまたはコルーチンはメッセージを送信することによってのみ共有リソースと対話できるようにすることですErlang/Golang を使用している場合は、この方法に精通しているはずですが、Rust ではチャネル データ構造を使用できます。
標準ライブラリであろうとサードパーティライブラリであろうと、Rust には優れたチャネル実装があります。同期チャネルには、標準ライブラリの mpsc:channel とサードパーティの Crossbeam_channel が含まれ、非同期チャネルには、tokio および flume の mpsc:channel が含まれます。

ネットワーク データを処理する一般的な方法
ほとんどの場合、HTTP などの既存のアプリケーション層プロトコルを使用してネットワーク データを処理できます。HTTP プロトコルでは、基本的に JSON を使用して REST API/JSON API を構築することが業界のコンセンサスであり、クライアントとサーバーもそのような処理をサポートする十分なエコシステムを備えています。serde を使用して、定義した Rust データ構造をシリアル化/逆シリアル化できるようにし、serde_json を使用してシリアル化された JSON データを生成するだけです。

パフォーマンスやその他の理由で独自のクライアント/サーバー プロトコルを定義する必要がある場合は、より効率的で簡潔な protobuf を使用してください。ただし、protobuf を使用してプロトコル メッセージを構築する場合は注意が必要で、protobuf は可変長のメッセージを生成するため、メッセージ フレームの定義方法についてクライアントとサーバーの間で合意する必要があります。**メッセージ フレームを定義するために一般的に使用される方法には、メッセージの末尾に「\r\n」を追加したり、メッセージのヘッダーに長さを追加したりする方法が含まれます。
この処理方法は非常に一般的であるため、Tokio は、長さで区切られたメッセージ フレームを処理するための length_delimited コーデックを提供しており、フレーム構造と組み合わせて使用​​できます。
先ほどの TcpListener / TcpStream コードと比較すると、StreamExt トレイトの next() インターフェイスを介して次のメッセージを取得するために、双方とも相手が送信したデータの長さを知る必要はなく、送信するときにのみ知っておく必要があります。 SinkExt トレイト インターフェイスの send() 呼び出しが送信されると、対応する長さが自動的に計算され、送信されるメッセージ フレームの先頭に追加されます。
サーバ


use anyhow::Result;
use bytes::Bytes;
use futures::{
    
    SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio_util::codec::{
    
    Framed, LengthDelimitedCodec};

#[tokio::main]
async fn main() -> Result<()> {
    
    
    let listener = TcpListener::bind("127.0.0.1:9527").await?;
    loop {
    
    
        let (stream, addr) = listener.accept().await?;
        println!("accepted: {:?}", addr);
        // LengthDelimitedCodec 默认 4 字节长度
        let mut stream = Framed::new(stream, LengthDelimitedCodec::new());

        tokio::spawn(async move {
    
    
            // 接收到的消息会只包含消息主体(不包含长度)
            while let Some(Ok(data)) = stream.next().await {
    
    
                println!("Got: {:?}", String::from_utf8_lossy(&data));
                // 发送的消息也需要发送消息主体,不需要提供长度
                // Framed/LengthDelimitedCodec 会自动计算并添加
                stream.send(Bytes::from("goodbye world!")).await.unwrap();
            }
        });
    }
}

クライアント


use anyhow::Result;
use bytes::Bytes;
use futures::{
    
    SinkExt, StreamExt};
use tokio::net::TcpStream;
use tokio_util::codec::{
    
    Framed, LengthDelimitedCodec};

#[tokio::main]
async fn main() -> Result<()> {
    
    
    let stream = TcpStream::connect("127.0.0.1:9527").await?;
    let mut stream = Framed::new(stream, LengthDelimitedCodec::new());
    stream.send(Bytes::from("hello world")).await?;

    // 接收从服务器返回的数据
    if let Some(Ok(data)) = stream.next().await {
    
    
        println!("Got: {:?}", String::from_utf8_lossy(&data));
    }

    Ok(())
}

コードを単純にするため、protobuf を直接使用しません。送受信された Bytes の内容は、protobuf によってシリアル化されたバイナリとみなすことができます (protobuf の処理を​​確認したい場合は、thumbor と kv サーバーのソース コードを確認できます)。LengthDelimitedCodec を使用すると、カスタム プロトコルの構築が非常に簡単になることがわかります。わずか 20 行のコードで、非常に複雑な作業が完了しました。

安全でない Rust: C++ で Rust を開くには?
ここに画像の説明を挿入します

Safe Rust はすべてのユースケースに適しているわけではありません。まず第一に、メモリの安全性のために、Rust によって作成されたルールは多くの場合普遍的であり、コンパイラはすべての不審な動作を厳密に抑制しますしかし、この細心の注意を払い冷酷な態度はしばしば厳しすぎて、不当な殺人につながる可能性があります。

第二に、Rust がどれほど純粋で完璧な内部世界を構築したとしても、ハードウェアであれソフトウェアであれ、純粋でも完璧でもない外部世界を常に扱わなければなりません。IO を操作して周辺機器にアクセスしたり、アセンブリ命令を使用して特殊な操作(GPU の操作や SSE 命令セットを使用)を実行したり
するなど、コンピューター ハードウェア自体が安全ではありません。コンパイラはそのような操作に対するメモリの安全性を保証できないため、コンパイラに慈悲を与えるために unsafe を指示する必要があります。同様に、Rust がC/C++ などの他の言語のライブラリにアクセスする必要がある場合、それらは Rust のセキュリティ要件を満たしていないため、このクロス言語 FFI (Foreign Function Interface) も安全ではありません。安全でない Rust を使用するこれら 2 つの方法は避けられないため、許容できるものであり、安全でない Rust を使用する必要がある主な理由です。

純粋にパフォーマンスを目的として安全でない Rust を使用するという大きなカテゴリもあります。たとえば、境界チェックのスキップ、初期化されていないメモリの使用などです。ベンチマークによって特定のパフォーマンスのボトルネックが unsafe を使用することで解決できることが判明しない限り、利益は損失を上回ります。安全でないコードを使用する場合、Rust のメモリ安全性を C++ と同じレベルに下げたためです。

さて、なぜ安全でない Rust が必要なのかを理解した後、安全でない Rust が日常業務でどのように使用されているかを見てみましょう。
安全でない特性の実装
Rust では、最も有名な安全でないコードはSend / Sync の 2 つの特性であるはずです。同時実行関連のコード、特にインターフェイスの型宣言に遭遇するたびに、これら 2 つの特性をよく理解しておく必要があると思います。 Send / Sync を使用して制約します。また、ほとんどのデータ構造は Send/Sync を実装していますが、Rc/RefCell/raw ポインターなどの例外がいくつかあることもわかっています。

pub unsafe 自動特性送信 {}
pub unsafe 自動特性同期 {}

Send/Sync は自動特性であるため、ほとんどの場合、独自のデータ構造で Send/Sync を実装する必要はありません。ただし、データ構造内で生のポインタを使用する場合、生のポインタは Send/Sync を実装しないため、データ構造は送信/同期を実装していません。
しかし、おそらくあなたの構造はスレッドセーフであり、スレッドセーフである必要もあります。この時点で、スレッド内で安全に移動できることが確認できれば Send を実装でき、スレッド内で安全に共有できることが確認できれば Sync を実装することもできます。前に説明した Bytes は、生のポインターを使用して Send/Sync を実装します:
安全でない特性は、特性の実装者に対する制約です。これは、特性の実装者に、「実装するときは注意してください。メモリの安全性を確保してください。そのため、次のことを追加する必要があります。」実装時に安全でないキーワードを使用します。
Unsafe fn は、呼び出し側の関数の制約です。関数の呼び出し側に、「無差別に me を使用すると、メモリ セキュリティの問題が発生します。適切に使用してください。したがって、unsafe fn を呼び出すときは、他の人に注意を促すための安全でないブロック。
ここに画像の説明を挿入します
優れた安全でないコードは十分に短く簡潔であり、必要なものだけが含まれています。安全でないコードは、開発者がコンパイラや他の開発者に対して行う厳粛な約束です。「このコードは安全であると誓います」というものです。今日のコンテンツのコードの多くはネガティブな教材なので、特に初心者には大量に使用することはお勧めできません。では、なぜ私たちは今でも安全でないコードについて話しているのでしょうか? 老子は「雄を知り、雌を守れ」と言いました。Rust の明るい面 (安全な Rust) をより簡単に保護できるように、Rust の暗い面 (安全でない Rust) を知る必要があります。

おすすめ

転載: blog.csdn.net/weixin_53344209/article/details/130130453