从内存使用角度的比较:Go vs Rust

发布时间 2023-10-17 20:21:52作者: dyf029

Go和Rust是最近几年非常火的语言,经常有人问到底该怎么选择,特别是谁更适合搭建网络后台服务,哪一个性能更好,稳定性更高。

网络上Go和Rust的比较文章很多,大体上是做一个测试或写几段测试代码,根据运行的时长来比较哪个性能更好,但这种测试可能会陷入误区:

1)比来比去,比的是网络IO,因为这种测试中语言特性在PK中占比很小,小到可以忽略。

2)无法模拟业务环境的重负荷下对性能和稳定性的影响。

这种测试也不符合实际情况,原因:

1)很少有业务场景需要极高的并发性能,因为业务负荷重,并发就做不高,高并发时服务的稳定性更重要。

2)如果性能确实不够了,优先去重构流程或架构,或多加几台机器做负载均衡。

 

当年做电信服务时(还在使用j2ee-ejb)out of memory是难以挥去的噩梦,所以本文是从内存角度来比较Go和Rust,测试在高并发下Go和Rust的内存使用情况。为了更好的做横向比较,将Java作为陪练一起PK。

 

先说一下测试环境:虚机环境做服务端,宿主机做客户端,使用这个环境主要是以下考虑:

1)每次测试都是重启虚机,这样可以保证所有测试的环境是稳定一致的。

2)宿主机(客户端)访问虚机(服务端),也可以保证网络环境是稳定一致的。

测试采样使用了图形化SSH工具软件OnTheSS( 下载 )。先看下虚机空载时的系统状态:

  • 系统:CentOS 7.9
  • CPU:AMD R7 4800U (笔记本CPU) 4个线程核(虚机),空载时CPU使用率很低且稳定。
  • 内存:总量3.77G(单位不同,实际是4G),已使用0.62G
  • 网络:ens33网卡每秒有11k的流量,虚机是CentOS带桌面的系统,所以本身有一定的网络流量。
  • TCP:只有22端口有连接,这2个连接是测试的shell终端和OnTheSSH工具的。

先把客户端代码贴出来,比较简单,即使你没有学过Rust语言,也不影响理解:

use std::net::TcpStream;
use std::io::{Read, Write};
fn main() 
{
    let msg = "abcdefg0123456789".as_bytes();
    let mut buf: [u8; 1024] = [0; 1024];
    for _ in 0..50000
    {
        let mut socket = TcpStream::connect("192.168.152.130:9000").unwrap();
        //发送
        socket.write_all(msg).unwrap();
        //接收
        let len = socket.read(&mut buf).unwrap();
        let recv_msg = std::str::from_utf8(&buf[0..len]).unwrap();
        println!("{}", recv_msg);
    }
}

客户端进行了5万次循环,每次循环都是从创建socket连接开始,发送一小段文字,再接收服务端返回的信息并打印出来。注意每次创建的socket并没有close,因为在rust语言中socket变量随生命周期的结束(循环结束时),会自动释放连接。

1、基准测试

 基准测试目的是证明在服务端轻负荷下,性能测试是区分不了语言特性的,三种语言的服务端代码如下:

 1)Go

package main

import (
  "fmt"
  "net"
)

func main(){
  listen, _ := net.Listen("tcp", ":9000")
  fmt.Printf("侦听端口 9000")
  buf := make([]byte, 1024)
  for {
    conn, _ := listen.Accept()
    //接收
    size, _ := conn.Read(buf)
//发送 size, _ = conn.Write(buf[0:size]) conn.Close() } }

服务端是简单的ECHO服务,创建TCP侦听端口9000,接收客户端发来的信息,再将信息发回,注意每次应答后立即关闭socket(短连接服务)。下面的Rust和Java语义相同。

2)Rust

use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
use std::io::{Read, Write};

fn main()
{
    let ip = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
    let port = 9000;
    let addr = SocketAddr::new(ip, port);
    println!("侦听端口: 9000");

    let mut buf: [u8; 1024] = [0; 1024];
    let listener = TcpListener::bind(addr).unwrap();
    loop
    {
        let (mut socket, _) = listener.accept().unwrap();
        //接收
        let len = socket.read(&mut buf).unwrap();
        //发送
        let send_msg = &buf[0..len];
        socket.write_all(send_msg).unwrap();
    }
}

3)Java

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;public class Echo 
{
    public static void main(String[] args) throws IOException 
    {
        ServerSocket server = new ServerSocket(9000);
        System.out.println("listen port 9000");
        
        byte[] buf = new byte[1024];
        while (true)
        {
            Socket socket = server.accept();
            //接收
            InputStream in = socket.getInputStream();
            int len = in.read(buf);
            //发送
            OutputStream out = socket.getOutputStream();
            out.write(buf, 0, len);
            //close
            socket.close();
        }
    }
}

 【测试结果】

用OnTHeSSH的网络监测来反馈基准测试的结果,5万次socket调用开始到结束(如下图)。和预想的结果一样,没有多大差别。基准测试中性能主要体现在socket读写,即使再换几种编程语言,结果应该也是差不离的。

2、模拟业务环境测试

基准测试非常特殊,而一般的业务环境不可能搭建这种“串行”的服务,另外在基准测试中服务没有载荷,纯粹是拼网络IO,考验的是Linux系统网络吞吐(TCP内核),和语言关系不大。

所以第二轮测试模拟了业务场景,改动有两处:第一是改为并行(多线程),第二是增加了5次UUID的获取来模拟任务负荷。

1)Go

package main

import (
  "fmt"
  "net"
  "github.com/google/uuid"
)

func main(){
  listen, _ := net.Listen("tcp", ":9000")
  fmt.Printf("侦听端口 9000")
  buf := make([]byte, 1024)
  for {
    conn, _ := listen.Accept()
    go func(){  
      //接收
      size, _ := conn.Read(buf)
      //业务负载,5次uuid的生成
      uuid.New().String()
      uuid.New().String()
      uuid.New().String()
      uuid.New().String()
      uuid.New().String()
      //发送
      size, _ = conn.Write(buf[0:size])
      conn.Close()
    }()
  }
}

 Go语言的并发使用了协程(用户态线程),理论上比内核管理的传统线程效率高。

2)Rust

use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
use std::io::{Read, Write};
use std::thread;
use uuid::Uuid;

fn main()
{
    let ip = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
    let port = 9000;
    let addr = SocketAddr::new(ip, port);
    println!("侦听端口: 9000");

    let mut buf: [u8; 1024] = [0; 1024];
    let listener = TcpListener::bind(addr).unwrap();
    loop
    {
        let (mut socket, _) = listener.accept().unwrap();
        thread::spawn(move ||{
          //接收
          let len = socket.read(&mut buf).unwrap();
          //业务负载,5次生成uuid
          let _uuid = Uuid::new_v4().to_string();
          let _uuid = Uuid::new_v4().to_string();
          let _uuid = Uuid::new_v4().to_string();
          let _uuid = Uuid::new_v4().to_string();
          let _uuid = Uuid::new_v4().to_string();
          //发送
          let send_msg = &buf[0..len];
          socket.write_all(send_msg).unwrap();
        });
    }
}

 Rust并发使用普通的线程机制,并没有使用Tokio异步库。

3)Java

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.UUID;

public class Echo 
{
    public static void main(String[] args) throws IOException 
    {
        ServerSocket server = new ServerSocket(9000);
        System.out.println("listen port 9000");
        
        byte[] buf = new byte[1024];
        while (true)
        {
            Socket socket = server.accept();
            new Thread(){
                @Override
                public void run()
                {
                    try
                    {
                        //接收
                        InputStream in = socket.getInputStream();
                        int len = in.read(buf);
                        //业务负载,5次uuid生成
                        UUID.randomUUID().toString();
                        UUID.randomUUID().toString();
                        UUID.randomUUID().toString();
                        UUID.randomUUID().toString();
                        UUID.randomUUID().toString();
                        //发送
                        OutputStream out = socket.getOutputStream();
                        out.write(buf, 0, len);
                        //close
                        socket.close();
                    }
                    catch (Exception e)
                    {
                        e.printStackTrace();
                    }
                }
            }.start();
        }
    }
}

 Java并发也是使用普通线程机制。

【测试结果】

1)性能:

为了避免陷入到谁家的UUID库的效率高的争论,本轮PK的重点不在效率上,但对比图还是贴一下。OnTheSSH提供的网络吞吐图没有反馈出时间长短的显著差异(横轴跨度),Go相对更平滑一些,可能是和用户态线程有关,但不影响大局。因此性能PK的结果和基准测试一样:还是差不多。

 2)内存:

再看OnTheSSH提供的内存使用状态:

内存差异性就非常明显了:物理内存使用最少的是Rust,700多K,其次是Go,11M,最多的是Java,300多M。虚存总量差异也是极大:Rust用了82M,Go用了912M,Java用了3.5G。

下图是OnTheSSH提供的进程状态对比,图太大有点看不清,注意划红线的两处对比,靠上红线处是进程运行过程中分配虚存的峰值的大小,靠下红线处是进行运行过程中分配到底物理内存峰值大小:

从内存使用角度PK,Rust是绝对领先的,差不多领先Go一个数量级,这点在测试前和我的预计相同,毕竟Go和Java都是带垃圾回收的语言,在高并发下内存回收有个过程。出乎预料的是Go和Java同是GC,但GC效果相差这么大,难怪当年经常碰到 Out of Memory。

在测试中只是用了5次UUID的生成来模拟业务负荷,实际应用中往往业务负荷要比这重得多,因此少用内存节省资源,是服务能长期可靠运行的必要条件。

3、测试结论

经过两轮测试,总结一下:

1、以绝对任务时间长短来比较语言的并发性能,没多大意义。

2、高并发下要更关注内存资源,从内存使用角度:Rust >> Go >> Java