当前位置: 首页 > 科技观察

Rust有一个GC,而且速度很快

时间:2023-03-12 13:14:27 科技观察

Rust越来越受欢迎。因此,无论Rust是否对我们具有战略意义,包括我在内的一群同事对它进行了半天的评估,以建立我们自己的观点。我们按照标准入门书进行了一些编码,查看了一些框架,并观看了“考虑Rust”演示文稿。.一般的结论是这样的:是的,一种不错的新编程语言,但是如果没有成熟的生态系统并且没有任何垃圾收集,它对我们的项目来说太麻烦和无用了。我的直觉不同意关于垃圾收集的评估。所以我做了一些进一步的挖掘和测试,并得出了我目前的结论:Rust会进行垃圾收集,但是是以一种非常聪明的方式。垃圾收集简史当你浏览Rust的网站并阅读介绍时,你会突然发现一个自豪的说法,即Rust没有垃圾收集器。如果你是我这个年纪,这会勾起一些不好的回忆。有时您必须使用malloc()手动分配内存,然后再释放它。如果过早释放它,您将受到诸如无效内存访问异常之类的攻击。如果您忘记释放它,就会造成内存泄漏,从而阻塞您的应用程序。很少有人第一次就做对。这一点都不好玩。在了解Rust采用的方法之前,让我们先简要了解一下垃圾到底是什么。在维基百科中,有一个很好的定义:垃圾包括数据……在其上运行的程序不会在以后的任何计算中使用。这意味着只有开发人员可以决定是否可以释放存储某些数据的内存段。但是,应用程序的运行时可以自动检测垃圾的子集。如果在某个时间点不再有对内存段的引用,程序将无法访问该段。而且,它可以安全地移除。为了实际实现这种支持,运行时必须分析应用程序中的所有活动引用,并且必须检查所有已分配的内存引用是否可以针对当前应用程序状态进行访问。这是一项计算密集型任务。在Java的第一天,JVM突然卡住了,不得不用相当长的时间进行垃圾回收。今天,有许多用于垃圾收集的复杂算法,通常与应用程序同时运行。然而,计算复杂度保持不变。从好的方面来说,应用程序开发人员无需担心手动释放内存段。永远不会出现无效的内存访问异常。她仍然可以通过引用不再需要的数据来造成内存泄漏。(恕我直言,主要示例是自己编写的缓存实现。老人的建议:永远不要使用像ehcache这样的东西。)然而,随着垃圾收集器的引入,内存泄漏越来越不常见了。Rust如何处理内存段乍一看,Rust很像C,尤其是在引用和解引用方面。但它有一种处理内存的独特方式。每个内存段都由一个引用拥有。从开发人员的角度来看,始终只有一个变量保存数据。如果此变量超出范围并且不再可访问,则将所有权转移给另一个变量或释放内存。使用这种方法,不再需要计算所有数据的可达性。相反,每次关闭命名上下文(例如,通过从函数调用返回)时,都会使用一个简单的算法来验证所用内存的可访问性。听起来好极了,以至于每个有经验的开发人员的脑海中可能都会立刻浮现出一个问题:问题是什么?问题是开发人员必须照顾好所有权。开发人员必须标记所有权,而不是在整个应用程序中随意散布对数据的引用。如果没有明确定义所有权,编译器将打印错误并停止工作。为了评估这种方法与传统垃圾收集器相比是否真的有用,我看到两个问题:开发人员在开发时标记所有权有多难?如果她所有的精力都集中在与编译器的交互上怎么办如果您正在战斗而不是解决域问题,这种方法可以做的不仅仅是帮助。与传统垃圾收集器相比,Rust解决方案快多少?如果收益不大,那又何必呢?为了回答这两个问题,我在Rust和Kotlin中执行了一项任务。此任务对于企业环境来说很典型,会产生大量垃圾。第一个问题是根据我个人的经验和意见来回答的,第二个问题是根据具体的测量来回答的。任务:使用数据库我选择的任务是模拟一个典型的以数据库为中心的任务,计算所有员工的平均收入。每个员工都被加载到内存中,并在循环中计算平均值。我知道你绝对不应该在现实生活中这样做,因为数据库可以自己更快地完成它。但是,首先,我在现实生活中经常看到这种情况,其次,对于一些NoSQL数据库,你必须在你的应用程序中这样做,其次,它只是一些代码,会产生大量需要收集的垃圾.我选择了JVM上的Kotlin作为具有代表性的基于垃圾回收的编程语言。JVM有一个高度优化的垃圾收集器,如果你习惯了Kotlin,使用Java就像在石器时代工作。您可以在GitHub上找到代码:https://github.com/akquinet/GcRustVsJvm在Kotlin中处理对一系列员工的计数,对他们的薪水求和,计算员工人数,最后除以这些数字:funcomputeAverageIncomeOfAllEmployees(employees:Sequence):Double{val(nrOfEmployees,sumOfSalaries)=employees.fold(Pair(0L,0L),{(counter,sum),employee->Pair(counter+1,sum+employee.salary)})returnsumOfSalaries.toDouble()/nrOfEmployees.toDouble()}这里没什么令人兴奋的。(你可能会注意到一种函数式编程风格,这是因为我非常喜欢函数式编程。但这不是本文的主题。)创建员工时会产生垃圾。我在这里创建随机员工以避免使用真实的数据库。但是,如果您使用JPA,您将创建相同数量的对象。funlookupAllEmployees(numberOfAllEmployees:Long):Sequence{return(1L..numberOfAllEmployees).asSequence().map{createRandomEmployee()}}随机对象的创建也很简单。String是从字符列表创建的charPool。funcreateRandomEmployee():Employee=Employee(createRandomStringOf80Chars(),createRandomStringOf80Chars(),...//codecutOut)funcreateRandomStringOf80Chars()=(1..80).map{nextInt(0,charPool.size)}.map(charPool:Rust版本的:get).joinToString("")中的一个小惊喜是我必须如何处理前面提到的字符列表。由于您只需要一个单例,因此将其存储在伴生对象中。这是它的大纲:classEmployeeServices{companionobject{privatevalcharPool:List=('a'..'z')+('A'..'Z')+('0'..'9')funlookupAllEmployees(...)...funcreateRandomEmployee():Employee...funcomputeAverageIncomeOfAllEmployees(...)...}}现在,我在Rust的做事方式中偶然发现的第一件事是将这个单例字符列表放在.Rust支持直接嵌入二进制的静态数据和可由编译器内联的常量数据。这两个选项都只支持一小组表达式来计算单例的值。我计算允许的字符池的解决方案是:letchar_pool=('a'..'z').collect::>();由于向量的计算基于类型推断,因此不能将其指定为常量或静态。我目前的理解是Rust的习惯用法是将函数需要处理的所有对象添加为参数。因此,在Rust中计算平均工资的主要调用如下所示:letaverage=compute_average_income_of_all_employees(lookup_all_employees(nr_of_employees,&char_pool,));使用这种方法,所有依赖关系都变得清晰。有C经验的开发人员会立即认出地址运算符&,它将内存地址作为指针返回,并且是高效且可能无法维护的代码的基础。当我的许多同事使用Rust时,这种基于C的负面体验被投射到Rust上。我认为这不公平。C设计的&运算符的问题在于总是存在不可预测的副作用,因为应用程序的每个部分都可以存储指向内存块的指针。此外,每个部分都可以释放内存,从而可能导致所有其他部分抛出异常。在Rust中,&运算符的工作方式不同。每条数据总是由一个变量拥有。如果使用此所有权创建对数据的引用,则该所有权将转移到引用范围。只有所有者才能访问数据。如果所有者超出范围,则可以释放数据。在我们的示例中,&运算符用于将char_pool的所有权转移到函数的参数。当函数返回时,所有权返回给变量char_pool。所以它是一个类似于C的地址运算符,但它添加了所有权的概念,从而使代码更简洁。Rust中的域逻辑Rust的主要特性看起来与Kotlin大致相同。由于隐含的数字类型,例如f6464位浮点数,感觉有点基础。但是,这是您很快就会习惯的事情。fncompute_average_income_of_all_employees(employees:implIterator)->f64{let(num_of_employees,sum_of_salaries)=employees.fold((0u64,0u64),|(counter,sum),employee|{return(counter+1,sum+员工).salary);});return(sum_of_salariesasf64)/(num_of_employeesasf64);}恕我直言,这是一个很好的例子,说明Rust是一种非常现代的干净编程语言,对函数式编程风格有很好的支持。在Rust中创建垃圾现在让我们看一下程序的一部分,其中创建了许多对象并需要稍后收集:Employee>+'a{return(0..number_of_all_employees).map(move|_|{returncreate_random_employee(char_pool);}).into_iter();}乍一看,这很像Kotlin。它使用相同的功能样式在循环中创建随机员工。返回类型为Iterator,类似于Kotlin中的序列,是一个惰性求值的列表。从第二个角度来看,这些类型看起来很奇怪。这是什么鬼?解决惰性求值问题。由于Rust编译器无法知道返回值何时实际计算,并且返回值取决于借用的引用,因此现在存在确定char_pool何时可以释放借用值的问题。'a注释指定char_pool的生命周期必须至少与返回值的生命周期一样长。对于习惯了经典垃圾回收的开发人员来说,这是一个新概念。在Rust中,她有时必须明确指定对象的生命周期。垃圾收集器会执行所有不需要的清理工作。第三,你可以找到移动关键字。它强制闭包获取它使用的所有变量的所有权。这是必要的,因为char_pool(再次)。Map是延迟执行的,因此,从编译器的角度来看,闭包可能比变量char_pool的生命周期还长。因此,闭包必须拥有它。其余代码非常简单。这些结构是从随机创建的字符串创建的:fncreate_random_employee(char_pool:&Vec)->Employee{returnEmployee{first_name:create_random_string_of_80_chars(char_pool),last_name:create_random_string_of_80_chars(char_pool),address:Address{//cutout..},salary:1000,};}fncreate_random_string_of_80_chars(char_pool:&Vec)->String{return(0..80).map(|_|{char_pool[rand::thread_rng().gen_range(0,char_pool.len())]}).into_iter().collect();}那么,Rust有多难?实现这个微小的测试程序非常复杂。Rust是一种现代编程语言,使开发人员能够快速、干净地维护代码。然而,它的内存管理概念直接反映在语言的所有元素中,开发人员必须了解这一点。恕我直言,工具支持很好。大多数时候,你只需要按照编译器告诉你的去做。但有时您必须真正决定如何处理数据。现在,值得吗?尽管这听起来有点令人信服,但我非常乐意做一些测量,看看现实是否也令人信服。因此,我针对四种不同的输入大小运行了Rust和Kotlin应用程序,测量了时间,并在对数刻度上绘制了结果:看着这些数字,我的脸很长。Rust总是比较慢;对于10^6元素,11是一个非常糟糕的因子。这是不可能的。我检查了代码,没有发现错误。然后我检查了优化并找到了--release标志以从开发模式切换到生产模式。现在,结果看起来好多了:好多了。现在,Rust总是比Kotlin更快,并提供线性性能。在Kotlin上,我们看到了长时间运行代码的典型性能改进,这可能是由于即时编译。从10^4的输入大小来看,Rust比Kotlin快大约3倍。考虑到JVM的成熟度和过去几十年在基础设施上投入的资源(Java的第一个版本于1995年发布),这是非常令人印象深刻的。令我惊讶的是,与生产配置文件相比,开发配置文件要慢得多。40的因数太大了,您永远不应该使用开发配置文件进行发布。结论Rust是一种现代编程语言,拥有您现在习惯的所有便利。它采用了一种新的内存处理方法,给开发人员带来了一些额外的负担,同时仍然提供了出色的性能。而且,要回答标题的原始问题,您不必在Rust中手动处理垃圾。这种垃圾收集是由运行时系统完成的,但不再称为垃圾收集器。