3k、60fps、130ms:用铁锈实现

2020-06-26 15:42:46

我们对托纳里的目标是建立一个通往另一个空间的虚拟通道,允许真正自然的人类互动。经过近两年的开发,据我们所知,Tonari是目前可用的最低延迟、高分辨率、随时可以生产的电话会议产品(我们真的不喜欢这个词)。

玻璃到玻璃的延迟130ms(从光线照射到摄像头出现在另一边屏幕上的时间)。

相比之下,Zoom和WebRTC的典型延迟为315-500ms,在我们办公室同一网络上的两台笔记本电脑(X1 Carbon和MacBook Pro)之间进行了测量。这是一个巨大的不同之处。这就是不断打断对方和自然交谈之间的不同之处。它的区别在于,摄像头拍摄的模糊面孔似乎指向某人的鼻子,而广域的高保真图像则能流畅地传递面对面交谈中所有微妙的肢体语言。

自从2月份启动我们的第一个试点以来,我们没有经历过与软件相关的停机(被以太网电缆绊倒则是另一回事)。尽管我们很乐意认为我们是绝对可靠的工程师,但我们真的不相信,如果没有铁锈,我们能够达到这样的稳定水平是不可能实现这些数字的。(工业和信息化部电子科学技术情报研究所陈皓)。

第一个托纳利概念验证使用了一个基本的投影仪、蓝牙扬声器和一个运行在普通WebRTC(JavaScript)之上的网站。自从那些日子以来,我们已经走过了很长一段路。

虽然这个原型(以及我们对未来的固执己见的愿景)为我们赢得了拨款,但我们知道,除非我们能实现比WebRTC低得多的延迟和更高的保真度--这两件事目前与2020年的视频聊天没有联系在一起,否则Tonari将在到达时就完蛋了。

我们想,“好的,所以我们可以直接修改WebRTC,并用C++用一个圆滑的UI来包装它,然后立即启动它。”

与WebRTC近75万个LoC庞大的代码库进行了一周的斗争,揭示了一个小小的更改可能会有多么痛苦-使用您正在处理的代码测试并感到真正安全是多么困难。

因此,在愤怒的(阅读:平静和彻底讨论)退出之后,我们决定从头开始重新实现整个堆栈更容易。我们想知道和理解在我们的硬件上运行的每一行代码,并且它应该是为我们想要的确切硬件而设计的。

于是,我们开始了超越高级界面(如浏览器或现有RTC项目)的深度之旅,并从头开始进入低级系统和硬件交互的世界。

我们需要它本质上是安全的,以保护那些使用Tonari的人的隐私。我们需要它的表现力,让它看起来尽可能人性化和实时性。随着代码变得更加成熟,随着新大脑的出现,我们需要它具有可维护性,并且必须学习我们的工作并在此基础上进行扩展。

安全性:C和C++是内存和并发不安全的,它们的不同且看似无限的构建系统使得获得一致而简单的开发体验变得困难。

性能:Java、C#和Go的内存管理是不透明的,在您希望完全控制内存的延迟敏感型应用程序中可能很难使用。

可维护性:Haskell、Nim、D和其他几种更定制的语言往往在工具、社区和可雇佣能力方面受到更多限制。

铁锈的美丽之处在于开发社区做出的无数决定,这些决定不断地让你觉得你可以吃到十个蛋糕,而且还可以吃掉所有的蛋糕。

它的构建系统是固执己见的,设计得很干净。它本身就是一个完整的生态系统,使向您的项目引入新工程师和设置开发环境变得非常简单。

内存和并发安全保证怎么评价都不为过。我们相信,如果我们在C++中继续这样做,我们还不会完成第一次部署-我们可能仍然会陷入微妙的障碍。

我们能够通过像CUDA这样的API在最低级别与硬件交互,通常是通过现有的机箱(Rust对代码库的术语),这使得我们能够对我们想要的第一个生产版本的延迟有更高的标准。

随着tonari变得越来越先进,我们现在正在选择嵌入式微控制器,它们的固件可以用铁锈编写,这样我们就不必离开我们田园诗般的乌托邦,进入不安全的系统编程的旧世界。

我们不打算在这里批评货架,而是集中在一些精选的板条箱上,这些板条箱获得了终身邀请参加我们每个生日派对的声望很高的奖项。

在几乎所有方面,CrossBeam都比std::sync::mpsc更适合线程间通信,并且最终可能会合并到std::sync::mpsc中。

parking_lot在几乎所有方面都比std::sync::mutex有一个更好的互斥体实现,并且可能会合并到标准库中(有朝一日)。它还提供了许多其他有用的同步原语。

与Vec<;U8&>相比,Bytes是一种更健壮、性能更好的字节处理方式。

如果您曾经进行过较低级别的网络优化,那么Socket2将是您最终的目标。

FERN是定制和美化您的日志记录输出的一种非常简单的方法。我们使用它来保持日志的可读性和内部标准化。

structopt是您一直梦想的CLI参数的处理方式。没有理由不使用它,除非您要追求最低限度的依赖。

货树(最近集成到货树中)显示了一个依赖树,它在许多方面都很有用,但主要用于确定最小化依赖关系的方法。

托纳利代码库是一种单一代码库。在它的根部,我们有一个货物工作区,里面有一个二进制文件箱,以及一些支持库的箱子。

将我们的板条箱放在一个repo中,这样就可以轻松地在我们的二进制板条箱中引用它们,而不需要发布到crates.io,也不需要在Cargo.toml中指定git依赖项。当将这些库作为开放源码发布时,将其拆分成自己的repo是微不足道的。

我们有一个主库机箱,其中包含用于与硬件、媒体编解码器、网络协议等对话的统一API。在该私有API之外,我们的工作区中也有独立的机箱,我们认为它们是开源的候选对象。例如,我们编写了适合长期运行的高吞吐量参与者的参与者框架,以及用于可靠、高带宽、低延迟媒体流的网络协议。我们对tonari系统的不同部分使用单独的二进制文件,每个部分都位于二进制文件中,即组合库/二进制箱。它的库模块包含一组可重用的参与者,这些参与者将我们的私有API与参与者系统结合在一起,然后是使用这些参与者并定义它们之间管道的一组单独的二进制文件。

我们广泛使用功能标志,以允许在不同的操作系统(如Brian&39;1970年代的MacBook Pro)或不同的硬件配置上开发我们的项目。例如,Linux使用V4L2(Video for Linux.2)来访问大多数网络摄像头,但其他网络摄像头可能有自己的SDK。要为不使用V4L2的平台进行编译,或者当SDK不适用于特定操作系统时,我们可以将这些SDK放在功能标志后面并导出通用接口。作为一个(简化的)具体示例,让我们假设我们有一个定义为特征的通用相机接口:(#*_)。

pub特征捕获{/从摄像头捕获一帧,返回一个RGB图像字节的VEC。FN捕获(&;mut self)->;Vec<;U8>;;}

让我们也假设我们有三个不同的相机接口-V4L2,核心视频和宝丽来。我们可以使我们的二进制文件只使用这一特性来灵活工作,并且我们可以使用功能标志交换不同的捕获实现。

Mod V4L2{pub struct V4l2Capture{.}针对V4l2Capture的实施捕获{fn Capture(&;mut self)->;Vec<;U8>;{.}模块核心视频{pub struct CoreVideoCapture{.}针对CoreVideoCapture的实施捕获{FN Capture(&;mut self)->;Vec<;U8&Gt。{.}mod宝丽来{pub struct PolaroidCapture{.}针对PolaroidCapture执行捕获{fn Capture(&;mut self)->;vec<;u8>;{.}pub type VideoCapture=V4L2::V4l2Capture;pub type VideoCapture=corevideo::CoreVideoCapture;pub type VideoCapture=Polaroid。

如果我们让我们的代码使用实现捕获特征的东西,而不是具体的类型,我们现在可以通过简单地切换功能标志在不同的平台上编译并瞄准不同的平台。例如,我们可以有一个具有field-video_Capture:box<;dyn Capture>;的结构,它允许我们存储可以从摄像机捕获的任何类型。例如,用于支持我们上面编写的捕获实现的Cargo.toml文件可能如下所示:

[软件包]名称=";tonari";版本=";1.0.0";版本=";2018";[功能]默认=[";V4L2";]MacOS=[";核心视频";]经典=[";宝丽来";]V4L2=[";rscam";][依赖项]rscam={版本="。0.5";,Optional=true}#V4L2 Linux摄像头库corevideo={Version=";0.1";,Optional=true}#MacOS摄像头库polaroid={version=";0.1";,Optional=true}#宝丽来相机库(非常慢的FPS)。

通过这种方式,我们可以避免构建和链接到特定于平台的库(如V4L2),这些库并不是随处可用的。

在转到铁锈公司一年后,我们让我们的第四位工程师加入了团队,他之前在铁锈公司或系统工程方面都没有太多经验。虽然学习曲线是不可否认的(借用检查器,我的老朋友),但我们已经发现,对于那些从新手到低级编程的人来说,Rust是一种令人难以置信的授权。

如前所述,内置于语言中的内存和并发安全意味着一整类问题不仅无法编译,而且编译器本身通常是您唯一需要的老师,因为它的警告非常具有描述性。关于Rust伟大的编译器信息,以及优秀的文档(例如,请看一下这篇关于字符串的冗长讨论),已经写了很多,在我们的例子中,这些也是非常有用的资源。

与许多其他语言不同,在Rust中通常有一种显而易见的正确方法。不是以正确的方式编写的代码往往会脱颖而出,并且很容易在审查中挑出来,通常是通过货物剪辑自动挑选出来的。

在实践中,这意味着新的工程师可以迅速开始贡献可投入生产的代码。代码审查可以将重点放在实现上,而不是花费精力进行手动正确性检查。

在IDE部门,与一些前辈相比,Rust仍然显示出相对的不成熟。特别是今年,虽然有了很大的进步,我们每个人在这一点上都找到了一个相当舒适的发展环境。·我们中有1人使用MacOS,3人使用Linux(Arch、Ubuntu和Pop!_OS,暴露出我们各自的受虐狂程度)·我们中有2人将VS Code与锈蚀分析仪插件一起使用,2人将Sublime Text与RustEnhanced一起使用。我们经常共享设置并尝试彼此的解决方案(除了布莱恩,他在29岁时步履维艰),我们还在不断关注能够帮助我们的新开发工具。

你知道什么叫狂野吗?在提交代码之前,我们没有您必须阅读的代码样式指南文档。我们不需要。我们只是强制执行“铁锈法”(Rustfmt)。让我告诉您:这真的降低了代码审查的优势。

我们的代码审查很简单,因为到目前为止我们只有四个人,而且我们很幸运,我们之间有很多信任。我们的主要目标是在每一行代码上至少有两双眼睛,并且不会互相阻挡,这样我们就可以保持势头。

我们使用Google的Cloud Builder来运行我们的CI构建,因为我们的基础架构堆栈主要构建在GCP之上,并且它允许轻松地调整构建机器规格和自定义构建映像。它是触发的每一次提交,并运行货物剪辑和货物建造。我们将-D警告传递给编译器以将警告升级为错误,以确保我们的更改不会使我们可怜的同事在下次拉出更改时发出生锈警告。为了缩短CI构建时间,我们在云存储中缓存目标和.Cargo目录,以便可以在下次进行增量构建时下载它。

对于$(ls*/Cargo.toml|xargs dirname)中的crate;执行PUSH$crate#Lint。Cargo+$Nightly_Toolchain clippy--no-default-Feature-D Warning#Build。时间RUSTFLAGS=";-D警告";货物构建--无默认-功能#测试。Time Cargo--无默认设置-已完成Popd功能。

铁锈生态系统很棒,但是有很多巨大的现有项目在没有巨大的时间投入的情况下不可能移植到铁锈上。WebRTC-Audio-Processing就是一个很好的例子。它提供的好处(清晰的音频,没有声音回声或反馈)是巨大的,在短期内将其移植到Rust是不太可能的(它大约需要80k行C和C++代码)。

值得庆幸的是,Rust使得使用现有的C和C++库变得相当容易。Bindgen板条箱承担了大部分的搬运任务。给它一个用C或C++编写的头文件,它将自动生成(不安全的)Rust代码,该代码可以调用头文件中定义的函数。在这一点上,它是由您创建一个更高级别的锈箱,暴露一个安全的API。

对于具有简单或常用构建过程的库来说,此过程中的很多都是相当自动化的。不过,创建更高级别的安全API很重要--bindgen提供的Rust API直接使用并不是很有趣,因为它不安全,而且通常不太习惯。幸运的是,一旦您拥有了更高级别的API,您最终可以将C库换成您自己的Rust版本,而机箱的使用者并不会更明智。

这些特性让我们可以使用API和硬件,而这些API和硬件要么永远没有原生的Rust API,要么需要几个月或几年的时间才能重新实现。低级操作系统库、大型代码库(如WebRTC-Audio-Processing)和制造商提供的Camera SDK都可以在我们的Rust代码库中使用,而不必将整个应用程序语言迁移到C++,同时仍能像我们那样运行。

一些C++库很难直接从Rust接口。您必须将类型列入白名单,因为bindgen不能处理所有引入的std::*类型,它不能很好地处理模板化函数和复制/移动构造函数,以及这里记录的大量其他问题。

为了解决这些问题,我们通常会创建一个简化的C++头和源代码包装器,用于导出绑定友好函数。这比将整个库移植到Rust要多一点,但工作量要小得多。您可以在这里看到此包装器创建的示例。

有了Rust的所有生态系统,而且C/C++项目只需一次绑定调用,我们就可以轻松访问现有的一些最高质量的软件包,所有这些都不需要牺牲执行速度。

生锈并不是没有问题的。它是一种相对较新的语言,正在不断发展,在评估迁移到Rust时,您应该考虑一些缺点。以下是我们的非详尽列表:

很长的编译时间;流行的xkcd漫画,在等待Rust代码编译时的咖啡休息是非常真实的。例如,我们的代码库在一台中等结实的笔记本电脑上进行非增量编译大约需要8分钟,但可能会更糟。Rust编译器有很多工作要做,以加强强大的语言保证,并且它必须从源代码编译整个依赖关系树。增量构建更好,但是一些机箱附带了拉取和编译非Rust依赖项代码的构建脚本,并且在升级版本和切换分支时可能需要清除构建缓存。

图书馆复盖率:图书馆生态系统相当成熟,但与C/C++相比复盖率有限。我们最终实现了自己的抖动缓冲区,并且我们还用Rust的bindgen包装了几个C/C++库,这意味着我们的Rust代码中有不安全的区域。非平凡的项目往往有一些最少量的不安全编码,这增加了学习曲线和内存错误的可能性。

Ruust要求您预先编写正确而明确的代码。如果你弄错了,编译器就不会让它溜走。如果您不太关心并发性和内存保证,开发可能会感到不必要的缓慢。不过,Rust开发人员一直在努力改进错误消息。他们很友好,而且可以行动,通常还会有一个固定的建议。一个好的内存和并发基础模型也有助于更快地克服最初的障碍,因此我们建议花时间真正理解语言及其保证。

Rust的类型推理器是如此强大,有时会让您感觉自己正在使用一种动态类型的语言。也就是说,总会有这样的时刻,它并不完全按照您想要的方式工作,特别是当涉及到泛型和deef强制时,您最终不得不摸索以取悦推理者。这可能会伴随着挫折而来,而团队中有一个已经经历过这个学习阶段的人真的很有帮助。如果有足够的耐心,这种挫败感通常会变成令人惊叹的时刻,对语言设计有了更深的理解,为什么会这样做,以及可能会引入的错误。

语言进化;铁锈语言在不断进化。有些语言结构(如异步)仍然是易失性的,您可能会发现,尽可能地坚持使用线程和标准库是最好的。

到目前为止,没有经历过与软件相关的停机时间,这既是一个惊喜,也是对铁锈公司提供的安全保证的证明。Ruust还使得编写高效资源使用的高性能代码变得容易-我们的CPU和内存使用都是可预测和一致的。没有垃圾收集器,我们可以保证一致的延迟和帧速率。

我们维护铁锈代码库的经验也很丰富。我们已经能够有信心地通过对代码库进行较大规模的更改来显著改善我们的延迟。干净的编译并不总是意味着一切都能正常工作,但老实说,情况往往如此。

最终的结果是一个可靠的产品,它不会成为维护的噩梦(我们知道这是强词夺理),并且在我们要求的帧率、延迟和资源效率的高规格下快速执行。再说一次,很难想象如果没有生锈我们会是什么样子!

到目前为止,我们已经开源了一个FFI板条箱,WebRTC-Audio-Processing。这是过去生活在我们回购的顶层的板条箱之一,还有更多类似的板条箱正在走向开源。

随着我们发布更多的代码,稍后会有更多关于这个主题的内容,但有一件事让人感觉是正确的:即使在我们的板条箱开源之前,假设我们私人装入的每个板条箱都将是开源的,这对我们的代码清晰度来说是非常健康的。这种哲学使我们的箱子之间的边界更加清晰,并鼓励我们更快地做出决定,以最小限度地大惊小怪地开放我们的代码库的某些部分。

感谢你走到这一步,我们希望这篇脑筋急转弯能提供一两个有用的想法。

..