Programación de red Rust y el módulo inseguro

En la capa de red, IPv4 e IPv6 actualmente compiten entre sí, e IPv6 no ha reemplazado completamente a IPv4; en la capa de transporte, excepto las aplicaciones que son muy sensibles a los retrasos (como el protocolo quic del juego), la mayoría de las aplicaciones usan TCP; mientras que en la capa de aplicación, es fácil de usar y La familia de protocolos HTTP compatible con firewall: HTTP, WebSocket, HTTP/2 y HTTP/3, que aún está en borrador, se ha destacado durante su larga evolución y se ha convertido en la corriente principal. elección para aplicaciones.
La biblioteca estándar de Rust proporciona std::net , que proporciona una encapsulación para el uso de toda la pila de protocolos TCP/IP. Sin embargo, std::net es síncrono, por lo que si desea crear una red asíncrona de alto rendimiento, puede utilizar tokio. tokio::net proporciona casi la misma encapsulación que std::net. Una vez que esté familiarizado con std::net, las funciones en tokio::net le resultarán familiares. Entonces, comencemos con std::net.
Método síncrono
TcpListener/TcpStream Si desea crear un servidor TCP, podemos usar TcpListener para vincular un puerto y luego usar un bucle para procesar las solicitudes recibidas del cliente. Después de recibir la solicitud, obtendrá un TcpStream, que implementa el rasgo de lectura/escritura y puede leer y escribir sockets como leer y escribir archivos:


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();
        });
    }
}

Para el cliente, podemos usar TcpStream::connect() para obtener un TcpStream. Una vez que el servidor acepta la solicitud del cliente, se pueden enviar o recibir datos:


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));
}

(Una desventaja aquí es que tanto el cliente como el servidor necesitan codificar el tamaño de los datos que se recibirán, lo cual no es lo suficientemente flexible. Veremos cómo manejarlo mejor usando marcos de mensajes más adelante).

Como puede ver en el código del cliente, no necesitamos cerrar explícitamente TcpStream, porque la implementación interna de TcpStream también maneja el rasgo Drop, por lo que se cerrará cuando salga del alcance.

Se puede ver que el hilo principal maneja nuevos enlaces y el proceso de procesamiento de la conexión debe realizarse en otro hilo u otra tarea asincrónica, en lugar de procesarlo directamente en el bucle principal, porque esto bloqueará el bucle principal y hará espera hasta que se complete el procesamiento. No se pueden aceptar nuevas conexiones () antes de la conexión actual. Esto es consistente con proyectos anteriores.

Sin embargo, el uso de subprocesos para manejar conexiones de red que se conectan y salen con frecuencia causará problemas de eficiencia. En segundo lugar, cómo compartir datos comunes entre subprocesos también es un dolor de cabeza, echemos un vistazo en detalle.

Si se crean subprocesos continuamente, cuando la cantidad de conexiones es alta, es fácil consumir todos los recursos de subprocesos disponibles en el sistema. Además, debido a que el sistema operativo completa la programación de subprocesos, cada programación debe pasar por un proceso de cambio de contexto de carga y guardado complejo y menos eficiente, por lo que si usa subprocesos, encontrará el cuello de botella de C10K, es decir, Cuando el número de conexiones alcance el nivel de decenas de miles, el sistema encontrará un doble cuello de botella en recursos y potencia informática**. Desde la perspectiva de los recursos, demasiados subprocesos ocupan demasiada memoria. El tamaño de pila predeterminado de Rust es 2 M, y 10k conexiones ocuparán 20 G de memoria (por supuesto, el tamaño de pila predeterminado también se puede modificar según sea necesario); desde la perspectiva Demasiados subprocesos alternarán entre sí cuando lleguen datos de conexión, lo que hará que la CPU esté demasiado ocupada y no pueda manejar más solicitudes de conexión. **
Se puede considerar que el grupo de subprocesos evita que se creen y destruyan subprocesos con frecuencia, pero el efecto puede no ser obvio.
Si queremos superar el cuello de botella de C10K y alcanzar C10M, solo podemos usar corrutinas en modo de usuario para el procesamiento, ya sean corrutinas apilables como Erlang/Golang o corrutinas continuas como el procesamiento asincrónico de Rust . Por lo tanto, en la mayoría de los códigos relacionados con la red en Rust, verá que pocos usan directamente std::net para el procesamiento, y la mayoría usa un tiempo de ejecución de red asíncrono, como tokio.
Aquí podemos compararlo con el anterior.

¿Qué se hace con la información compartida?
Dos preguntas: Al construir un servidor, siempre tendremos algún estado compartido para que lo utilicen todas las conexiones, como las conexiones de bases de datos. Para tal escenario, si no es necesario modificar los datos compartidos, podemos considerar usar Arc. Si es necesario modificarlos, podemos usar Arc>.

Pero el uso de bloqueos significa que una vez que sea necesario acceder a los recursos bloqueados en la ruta crítica, el rendimiento de todo el sistema se verá muy afectado.
Una forma de pensar es que reducimos la granularidad del bloqueo para reducir los conflictos. Por ejemplo, en el servidor kv, aplicamos hash al módulo de clave N y asignamos diferentes claves a almacenes de memoria N. De esta manera, la granularidad del bloqueo se reduce al 1/N anterior:

Otra idea es cambiar el método de acceso a los recursos compartidos para que solo un hilo específico pueda acceder a ellos; otros hilos o corrutinas solo pueden interactuar con ellos enviándoles mensajes . Si usa Erlang/Golang, debería estar familiarizado con este método. En Rust, puede usar la estructura de datos del canal.
Existen excelentes implementaciones de canales en Rust, ya sea la biblioteca estándar o una biblioteca de terceros. Los canales sincrónicos incluyen mpsc:channel de la biblioteca estándar y crossbeam_channel de terceros. Los canales asincrónicos incluyen mpsc:channel en tokio y flume.

Métodos generales para procesar datos de red
La mayoría de las veces, podemos utilizar protocolos de capa de aplicación existentes para procesar datos de red, como HTTP. Bajo el protocolo HTTP, existe consenso en la industria para usar básicamente JSON para construir API REST/API JSON, y el cliente y el servidor también tienen un ecosistema lo suficientemente bueno para soportar dicho procesamiento. Solo necesita usar serde para hacer que la estructura de datos de Rust que defina sea capaz de serializar/deserializar, y luego usar serde_json para generar datos JSON serializados.

Si es posible que necesite definir su propio protocolo cliente/servidor por motivos de rendimiento u otros motivos, utilice el protobuf, más eficiente y conciso. Sin embargo, debe prestar atención al utilizar protobuf para crear mensajes de protocolo. Debido a que protobuf genera mensajes de longitud variable, es necesario llegar a un acuerdo entre el cliente y el servidor sobre cómo definir un marco de mensaje. **Los métodos comúnmente utilizados para definir marcos de mensajes incluyen agregar "\r\n" al final del mensaje y agregar longitud al encabezado del mensaje.
Debido a que este método de procesamiento es muy común, Tokio proporciona el códec length_delimited para procesar marcos de mensajes separados por longitud, que se puede usar junto con la estructura Enmarcado.
En comparación con el código TcpListener / TcpStream de ahora, ambas partes no necesitan conocer la longitud de los datos enviados por la otra parte para recibir el siguiente mensaje a través de la interfaz next() del rasgo StreamExt; al enviar, solo necesitan Llame al envío () de la interfaz de rasgo SinkExt, la longitud correspondiente se calcula automáticamente y se agrega al comienzo del marco del mensaje que se enviará.
Servidor


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();
            }
        });
    }
}

cliente


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(())
}

Por simplicidad del código, no uso protobuf directamente. Puede considerar el contenido de los Bytes enviados y recibidos como el binario serializado por protobuf (si desea ver el procesamiento de protobuf, puede revisar el código fuente de thumbor y el servidor kv). Podemos ver que al usar LongitudDelimitedCodec, crear un protocolo personalizado se vuelve muy simple. Sólo veinte líneas de código completaron un trabajo muy complejo.

Rust inseguro: ¿Cómo abrir Rust en C++?
Insertar descripción de la imagen aquí

Safe Rust no es adecuado para todos los casos de uso. En primer lugar, por el bien de la seguridad de la memoria, las reglas creadas por Rust suelen ser universales y el compilador suprimirá estrictamente todos los comportamientos sospechosos . Sin embargo, esta actitud meticulosa y despiadada a menudo puede resultar demasiado dura y provocar homicidios injustos.

En segundo lugar, no importa cuán puro y perfecto construya Rust su mundo interno, siempre tiene que lidiar con el mundo exterior, ya sea hardware o software, que no es ni puro ni perfecto.
El hardware de la computadora en sí no es seguro, como operar IO para acceder a periféricos o usar instrucciones de ensamblaje para realizar operaciones especiales (operar la GPU o usar el conjunto de instrucciones SSE). El compilador no puede garantizar la seguridad de la memoria para dicha operación, por lo que necesitamos decirle al compilador que sea misericordioso. De manera similar, cuando Rust necesita acceder a bibliotecas en otros lenguajes como C/C++ , debido a que no cumplen con los requisitos de seguridad de Rust, esta FFI (Interfaz de función extranjera) en varios idiomas tampoco es segura. Estas dos formas de utilizar Rust inseguro son inevitables, por lo que son excusables y son las razones principales por las que necesitamos utilizar Rust inseguro.

También existe una gran categoría de uso de Rust inseguro únicamente por motivos de rendimiento. Por ejemplo, saltarse las comprobaciones de límites, utilizar memoria no inicializada, etc. Deberíamos tratar de no usar unsafe de esta manera. A menos que descubramos a través de evaluaciones comparativas que ciertos cuellos de botella de rendimiento se pueden resolver usando unsafe , las ganancias superan las pérdidas. Porque, al usar código inseguro, hemos reducido la seguridad de la memoria de Rust al mismo nivel que C++.

Bien, después de comprender por qué se necesita Rust inseguro, echemos un vistazo a dónde se usa Rust inseguro en el trabajo diario.
Implementación de rasgos inseguros
En Rust, el código inseguro más famoso deberían ser los dos rasgos Enviar/Sincronizar : Creo que deberías conocer muy bien estos dos rasgos. Siempre que encuentres código relacionado con la concurrencia, especialmente la declaración de tipo de interfaz, es esencial. utilizar Enviar/Sincronizar para restringir. También sabemos que la mayoría de las estructuras de datos implementan Envío/Sincronización, pero hay algunas excepciones, como Rc/RefCell/punteros sin formato, etc.

pub rasgo automático inseguro Enviar {}
pub rasgo automático inseguro Sincronizar {}

Debido a que Enviar/Sincronizar es una característica automática, en la mayoría de los casos, su propia estructura de datos no necesita implementar Enviar/Sincronizar. Sin embargo, cuando usa punteros sin formato en la estructura de datos, debido a que los punteros sin formato no implementan Enviar/Sincronizar, incluso su La estructura de datos no implementa Envío/Sincronización.
Pero lo más probable es que su estructura sea segura para subprocesos y usted también necesite que lo sea. En este punto, si puede asegurarse de que se pueda mover de forma segura en el hilo, puede implementar Enviar; si puede asegurarse de que se pueda compartir de forma segura en el hilo, también puede implementar Sync. Los Bytes que discutimos antes implementan Envío/Sincronización utilizando punteros sin formato: el
rasgo inseguro es una restricción para el implementador del rasgo. Le dice al implementador del rasgo: Tenga cuidado al implementarme y garantice la seguridad de la memoria. Por lo tanto, debe agregar la palabra clave insegura al implementarla.
fn inseguro es la restricción de la función en la persona que llama. Le dice a la persona que llama de la función: si me usa indiscriminadamente, causará problemas de seguridad de la memoria. Úselo correctamente. Por lo tanto, cuando llame a fn inseguro, debe agregar un bloque inseguro para recordar a los demás que presten atención.
Insertar descripción de la imagen aquí
Un buen código inseguro es lo suficientemente breve y conciso, y sólo contiene lo necesario. El código inseguro es una promesa solemne hecha por el desarrollador al compilador y a otros desarrolladores: juro que este código es seguro. Muchos de los códigos del contenido de hoy son materiales didácticos negativos y no se recomienda su uso frecuente, especialmente para principiantes. Entonces, ¿por qué seguimos hablando de código inseguro? Laozi dijo: Conoce al macho y protege a la hembra. Necesitamos conocer el lado oscuro de Rust (óxido inseguro) para que podamos proteger más fácilmente su lado brillante (óxido seguro).

Supongo que te gusta

Origin blog.csdn.net/weixin_53344209/article/details/130130453
Recomendado
Clasificación