首页> 标签> Rust
"Rust"
共 399 条结果
全部 问答 文章 公开课 课程 电子书 技术圈 体验
如何开发 Node.js Native Add-on?
作者 | 吴成忠(昭朗)这篇文章是由 Chengzhong Wu (@legendecas),Gabriel Schulhof (@gabrielschulhof) ,Jim Schlight (@jimschlight),Kevin Eady,Michael Dawson (@mhdawson1),Nicola Del Gobbo (@NickNaso) 等人编写的,首发在 Node.js Medium 博客。关于N-APIN-API 为 Node.js 带来了一个 ABI 稳定的 add-on API,简化了构建和开发支持跨 Node.js 版本的 add-on 的负担。目前 N-API 的 C++ 封装 node-addon-api 每周的下载量已经超过了 250万次,并且所有 Node.js LTS(长期支持版本)都已经支持了 N-API v3 或者更高版本 ,Node.js 15.x 更已经开始支持最新的 N-API v7。所以我们认为这是一个非常好的时间点来回头看一看目前 Node.js add-on 的开发体验。当我们在 2016 年开始投入 N-API 的工作(最开始的提案是在 2016 年 12 月 12 日提出的),我们就知道这会是一个非常长期的任务。Node.js 社区生态中已经有非常多现存的包,所以这个迁移过程将会持续相当长的一段时间。不过好消息是,从最初的想法,到现在这段路程我们已经走过了非常长的路途。许许多多的困难已经由多位 Node.js Collaborator、N-API 团队和模块包作者们攻克。目前,N-API 已经成为了默认、推荐的编写 Node.js add-on 的方式。随着 N-API 的发展,不断有新的 API 加入到 N-API 中去来满足 Node.js 模块包作者将他们的库向 N-API 迁移中提出新需求,当然这个过程也按照我们预先的设计 N-API 一直保持着稳定、向前兼容性。我们也非常高兴地看到这些模块包作者们的积极反馈,比如 https://twitter.com/mafintosh/status/1256180505210433541不多说,我们先来看看过去几年被添加到 N-API 中的新特性吧。新特性越来越多的开发者们开始使用 N-API 与 node-addon-api 开发 Node.js add-on,我们也不断地为 N-API 和 node-addon-api 添加新的关键特性和改进 add-on 开发体验。这些改进可以分为 3 个主要的类别,我们下文将一一介绍。多线程与异步编程随着 Node.js 的使用在开发者群体中越来越显著,需要与 OS 接口、异步事件打交道的需求也越来越旺盛。Node.js 是一个 JavaScript 单线程模型的实现,一个 Node.js 环境只会有一个主线程可以访问 JavaScript 值。因此,在主线程执行重 CPU 的任务就会导致 JavaScript 程序被阻塞,导致事件与回调都堆积在事件队列中。为了改进程序的跨线程数据完整性的开发体验,我们收集了非常多的真实案例的需求,在 N-API 和 N-API 的 C++ 封装 node-addon-api 中都带来了多种机制来解决工作线程回调回 JavaScript 线程的问题。根据使用场景,可以分为:AsyncWorker,提供单向、单次的回调任务封装,可以通知 JavaScript 这个任务的最终执行结果或者异常信息;AsyncProgressWorker,与 AsyncWorker 类似,提供单向、单次的回调任务封装,不过增加了向 JavaScript 异步传递进度信息的机制;Thread-safe functions,提供了从任意线程、任意数量的线程、任意时间点向 Node.js JavaScript 线程回调的机制。多 Node.js 上下文支持Node.js 近期最让人兴奋的特性之一就是 [worker_threads],它提供了一个完整的、但是独立于 Node.js 主 JavaScript 线程的并发执行的 Node.js JavaScript 执行线程。这也意味着 Node.js 的 add-on 也同样可以在这些 worker 线程中随着这些 worker 的启动与销毁被多次加载、卸载。不过因为这些同一个进程中的 worker 线程是共享了同一个内存空间的,多个 add-on 的实例必须考虑到多个 worker 线程的同时存在的可能性。另外,每一个 Node.js 进程只会加载了一次这些 add-on 的动态库,这意味着这些 add-on 线程不安全的全局属性(比如全局静态变量)可以被多个线程同时访问,也就不能再这么简单粗暴地存储了。类似的,C++ 类的静态数据成员也是通过线程不安全的方式存储的,所以这个方式也需要被避免。另外,其实对于 add-on 来说,Node.js 也不保证单个线程只会用来执行一个 worker,所以 thread-local 也应该被避免。在 N-API v6 中,我们为每一个 Node.js 实例(主线程 JavaScript 实例、worker 实例等)都引入了一个用来给 add-on 使用的存储空间。这样,add-on 在一个进程中就可以获得对于单个 Node.js 实例唯一的存储空间了。同时我们也提供了一些辅助方法来帮助 add-on 开始使用这个特性:NAPI_MODULE_INIT() 宏,会将 add-on 标记为可以被 Node.js 在同一个进程中可以多次加载、卸载的模块。napi_get_instance_data() 和 napi_set_instance_data() 用来安全地访问单个 Node.js 实例给 add-on 创建的全局唯一存储空间;node-addon-api 还提供了 Addon<T> 类,这个类包装了上面说所的方法,以 C++ 友好的方式封装了这个给予 add-on 可以在不同的 worker 线程中使用的存储空间。因此,add-on 开发者可以将 add-on 的数据比如全局变量通过 Addon<T> 来存储并创建,而 Node.js 则会负责在当前线程使用这个 add-on 的时候创建这片空间。其他辅助函数除了以上几个重要功能之外,我们也发现了许多在维护 Node.js add-on 的过程中经常会使用到的类型方法与函数,包括:Date 对象;BigInts;从 JavaScript 对象上获取任意键(如 Symbol 等);将 Add-on 创建的 ArrayBuffer 底层存储从 ArrayBuffer 上脱离;构建构建工作流对于 Node.js add-on 维护者与 add-on 使用者来说是非常重要的一个环节,也是N-API 团队其中一个工作重心,比如 CMake.js, node-pre-gyp 和 prebuild。曾经 Node.js add-on 只能使用 node-gyp 来构建。对于一些已经在使用 CMake 的库来说,CMake.js 就是除了 node-gyp 依赖用来构建 add-on 的一个非常吸引人的选项。我们也已经发布了一个使用 CMake 构建 add-on 的例子。其他关于如何将 CMake.js 与 N-API add-on 一起使用的详细信息可以在 N-API Resource 获取到。开发 Node.js add-on 之后一个重要的现实问题就是在 npm install 时,add-on 的 C/C++ 代码必须在本地编译、链接。这个编译过程需要本地安装有一个可以正常使用的 C/C++ 工具链。而这个依赖通常会成为没有安装这些工具链的 add-on 用户使用这个 add-on 的一个阻碍。现行的方案对于这个问题一般都是预先构建二进制包,然后在安装时直接下载这些预先构建的包。有许多工具可以用来预先构建二进制包。node-pre-gyp 通常会将构建出来的二进制包上传到 AWS S3。prebuild 也类似,不过是将包上传到 GitHub Release。prebuildify 则是另外一个可选项。而 prebuildify 相比于上述的工具来说,优点在于在 npm install 安装好时,本地就已经有这些二进制包了,而不需要再次从第三方服务上下载。虽然安装的 npm 包可能会更大,不过在实际实践中因为不需要再次从 AWS 或者 GitHub 上下载,整个安装过程会相对更加快速。开始上手我们已经在 GitHub 上准备了非常多的 node-addon-examples 来给开发者快速了解常见场景该如何使用 N-API 和 node-addon-api 来开发 Node.js add-on。这个仓库的根目录包含了许多的文件夹,这些文件夹就代表了不同的使用场景,比如从简单的 Hello World add-on,到复杂的多线程 add-on。每一个样例目录会包含 3 个子目录,分别代表了传统的 NAN,N-API,和 node-addon-api 开发 add-on 的例子。我们可以直接运行下面的命令,立刻从 Hello World 的例子开始使用 node-addon-api:$ git clone https://github.com/nodejs/node-addon-examples.git $ cd node-addon-examples/1_hello_world/node-addon-api/ $ npm i $ node .另一个重要的资源就是 N-API Resource。这个网站包含了开发、构建 Node.js add-on 的从入门到深入的许多信息与资料,比如上手所需的工具;从 NAN 向 N-API 的迁移导引;不同构建系统的对比(node-gyp,CMake 等等);多 Node.js 上下文支持和线程安全。结语从 Node.js 诞生之初,Node.js 就支持通过 C/C++ 代码来给 JavaScript 暴露更多的特性接口。随着时间积累,我们也认识到实现、维护、分发这些 add-on 一直存在许许多多的难点。而 N-API 就被 add-on 维护者们认为是解决这些难点的一个非常核心的领域。所以整个N-API 团队和社区都开始为 Node.js 核心建立起这样一套 ABI 稳定的 add-on API。而代表了 N-API 的这些 C API 现在已经是每一个 Node.js 发布版本的一部分,并且我们也有了可以通过 npm 安装的 node-addon-api 来提供这些 C API 的 C++ 封装。N-API 在诞生之初,就是以在不同 Node.js 版本之间,甚至是 Major 版本之间保证 ABI 与 API 兼容性为目标,而这也已经可以证明能够提供更多额外的好处:我们不再需要在切换 Node.js 大版本之后重新编译 add-on 模块;我们可以在除了使用 V8 作为 JavaScript 引擎的 Node.js 之外的运行环境实现 N-API,也意味着这些为 Node.js 开发的 add-on 无需修改任何代码即可兼容这些运行环境,比如 Babylon Native,IoT.js 和 Electron。N-API 是单纯的 C API,这意味着我们可以使用 C/C++ 之外的语言、运行时开发 Node.js add-on,比如 Go 或者是 Rust。N-API 从 Node.js v8.0.0 开始以实验性功能发布到现在,虽然广泛应用的过程比较缓慢,但是模块开发者们也不断地给我们提交反馈与贡献,这也帮助我们不断地增加新特性和开发新的工具来帮助开发者们构建一个更好的 add-on 生态。今天,N-API 在 add-on 的开发中使用已经非常广泛。比如一些使用非常多的 add-on 模块都已经迁移至基于 N-API 开发:sharp (每周 ~900k 下载量)bcrypt (每周 ~500k 下载量)sqlite3 (每周 ~300k 下载量)在过去的几年中,N-API 获得了非常多的改进。而对于 add-on 开发者与用户来说,这也给他们带来了接近于原生 JavaScript 模块的开发、使用体验。开始贡献我们在持续不断地改进 N-API 和 Node.js 的 add-on 生态,但是我们也一直非常需要帮助。你可以在以下途径在多种场景帮助 N-API 做的更好:将你的 add-on 迁移到 N-API;帮助你的应用依赖的 add-on 迁移到 N-API;为 N-API 提出、实现新的特性;为 node-addon-api 提出、实现新的基于 N-API 的特性;为 node-addon-api 修复问题、增加测试用例;为 node-addon-examples 修复问题、增加测试用例;如果你对加入我们的工作感兴趣,可以查阅 https://github.com/nodejs/abi-stable-node#meeting 来加入我们每周的工作组会议。
文章
存储  ·  Rust  ·  JavaScript  ·  前端开发  ·  测试技术  ·  API  ·  开发工具  ·  C++  ·  开发者  ·  git
2021-08-09
Rust的Future、GO的Goroutine、Linux的Epoll高并发背后的殊途同归
今天我们继续高并发的话题,在上次的博客中我们有提到,Rust的Future机制非常有助于程序员按照更为自然、简洁的逻辑去设计系统,我们必须要知道高并发系统的关键在于立交桥的分流与导流构造而非信号灯的限流。因此把精力放在设计锁、互斥系这些信号系统上是非常事倍功半的。从机制上来讲Rust从函数式语言借鉴而来的Future机制是先进的,而且从亲身教小孩编程的时候笔者意外发现,对于没有任何编程经验的人来说,他们学习async/await的成本,要比理解层层回调的机制要低得多。程序员在学习Future的难度大,其实完全是因为之前的历史包袱太重了。为什么说Future更像自然语言 在以下这段代码中,网络连接socket、请求发送request、响应接收response三个对象全部都是future类型的,也就是在代码执行之后不会被执行也没有值仅有占位的意义,当未来执行后才会有值返回,and_then方法其实是在future对象执行成功后才会被调用的方法,比如read_to_end这行代码就是在request对象执行成功后,调用​​read_to_end方法对读取结果。​​use futures::Future;​​use tokio_core::reactor::Core;​​use tokio_core::net::TcpStream;​​fn main() {​​ let mut core = Core::new().unwrap();​​ let addr = "127.0.0.1:8080".to_socket_addrs().unwrap().next().unwrap();​​ let socket = TcpStream::connect(&addr, &core.handle());​​​​​ let request = socket.and_then(|socket|{​​ tokio_core::io::write_all(socket, "Hello World".as_bytes())​​ });​​ let response = request.and_then(|(socket, _)| {​​ tokio_core::io::read_to_end(socket, Vec::new())​​ });​​​​​ let (_, data) = ​​core.run​​(response).unwrap();​​ println!("{}", String::from_utf8_lossy(&data));​​ }​​​​而想象一下如果是传统编程所采用的方式,需要在网络连接完成后调用请求发送的回调函数,然后再请求发送的响应处理方法中再注册接收请求的回调函数,复杂不说还容易出错。而future机制精髓之处在于,整个过程是通过core.run(response).unwrap();这行代码运行起来的,也就是说开发人员只需要关心最终的结果就可以了。从建立网络连接开始的调用链交给计算机去帮你完成,最终的效率反而还会更高。并发中的poll模式到底是什么意思?笔者看到不少博主在介绍Rust的Future等异步编程框架时都提到了Rust的Future采用poll模式,不过到底什么是poll模式却大多语焉不详。笔者还是这样的观点,程序员群体之所以觉得future机制难以理解,其关键在于思维模式被计算机的各种回调机制给束缚住了,而忘记了最简单直接的方式。在解决这个问题之前我们先来问一个问题,假如让我们自己设计一个类似于goroutine之类事件高度管理器,应该如何入手?最直接也是最容易想到的方案就是事件循环,定期遍历整个事件队列,把状态是ready的事件通知给对应的处理程序,这也是之前mfc和linux的select的方案,这实际上也就是select方案;另外一种做法是在事件中断处理程序中直接拿到处理程序的句柄,不再遍历整个事件队列,而是直接在中断处理响应中把通知发给对应处理进程,这就是Poll模式。多路复用是另一种机制,这种机制可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。笔者在前文《这位创造了Github冠军项目的老男人,堪称10倍程序员本尊》中曾经介绍过Tdengine的定时器,其中就有这种多路复用的思想。由于操作系统timer的处理程序还不支持epoll的多路复用,因此每注册一个timer就必须要启动一个线程进行处理,资源浪费严重,因此Tdengine自己实现了一个多路复用的timer,可以做到一个线程同时处理多个timer,这些细节上的精巧设计也是Tdengine封神的原因之一。Epoll的代价-少量连接场景不适用当然epoll还有一个性能提升的关键点,那就是使用红黑树做为事件队列的存储模型,我们在上文《用了十年竟然都不对,Java、Rust、Go主流编程语言的哈希表比较》中曾经提到过,红黑树是一种解决哈希碰撞时比较好的退化选择,不过这也给epoll机制带来了一些适用场景的限制,如果连接总数本身就不高的情况下,那么epoll可能还不如select高效。其原因同时也在《用了十年竟然都不对,Java、Rust、Go主流编程语言的哈希表比较》中说明了,由于红黑树在内存中也是散列的状态,这就会造成连续存储的数据在总长度较小的情况下获得比红黑树更好的性能,具体这里就不加赘述了。ET还是LT如何触发又是个选择Epoll的触发又分为水平触发和垂直触发两种模式,具体介绍如下:LT(level triggered)水平触发,是缺省的工作方式,顾名思义,也就是即使状态不变也可能模式通知的模式,同时支持阻塞和非阻塞两种方案.在这种做法中,内核通知注册的进程一个有任务已经就绪,不过这种模式下就算进程不作任何操作,内核还是会继续通知,所以这种模式属于唐僧式的模式,虽然唠叨但出BUG的可能性要小一点。ET (edge-triggered),垂直触发,也就是当且仅当有任务状态发生变化时才会被触发,属于高速工作方式。在ET模式下仅当有事件从未就绪变为就绪时,内核才会触发通知。但是内核的通知只会发出一次,也就是说如果事件一直没有进程处理,内核也不会发送第二次通知。其实从代码来看ET和LT的差别不多,具体如下:​if (epi->​​event.events​​ & EPOLLONESHOT) ​​ epi->​​event.events​​ &= EP_PRIVATE_BITS; ​​else if (!(epi->​​event.events​​ & EPOLLET)) { //如果是是LT模式,当前事件会被重新放到epoll的就绪队列。 ​​list_add_tail(&epi->rdllink, &ep->rdllist); ​​ep_pm_stay_awake(epi);​​}​可以看到LT模式从不会丢弃事件,只要队列里还有数据能够读到,就会不断的发起通知,属于链式反应的一种,效率低点但不容易出错,而ET只在则只在新事件到来时才会发起通知,效率高但也容易出BUG。当然如果socketfd事件与处理线程之间是一对多的关系,也就是说一个socketfd只对应一个线程,那倒也还好说。但由于在很多高并发的场景下,很多socketfd是由多个进程同时监控的,因此这又会造成一个惊群的问题。正如前文所说,多路复用机制也允许多个进程(线程)在等待同一个事件的到来,当这个 fd(socket)的事件发生的时候,这些睡眠的进程(线程)就会被同时唤醒,去处理这个事件,这和一大群鱼,争抢一个鱼食的现象非常类似,因此也就被称为"惊群"现象。由于大量的进程计算资源被浪费在被抢食的过程中,实际上却没做任何有意义的工作,因此"惊群"效率低下,而且在鱼群抢食的过程中,会造成系统短暂的吞吐能力下降。对于流量分布极不均衡的系统来说,惊群的影响很大。不过在LT模式下,通知是链式的,因此惊群难以避免,ET模式下效率虽多,但如果有一个进程出现问题,则很有可能造成难以察觉的BUG,高并发系统绝对是个说起来容易,做起来难的设计。
文章
存储  ·  Rust  ·  自然语言处理  ·  监控  ·  Java  ·  程序员  ·  Linux  ·  Go
2021-08-07
内核热补丁,真的安全么?
Linux 内核热补丁可以修复正在运行的 linux 内核,是一种维持线上稳定性不可缺少的措施,现在比较常见的比如 kpatch 和 livepatch。内核热补丁可以修复内核中正在运行的函数,用已修复的函数替换掉内核中存在问题的函数从而达到修复目的。函数替换的思想比较简单,就是在执行旧函数时绕开它的执行逻辑而跳转到新的函数中,有一种比较简单粗暴的方式,就是将原函数的第一条指令修改为“ jump 目标函数”指令,即直接跳转到新的函数以达到替换目的。那么,问题来了,这么做靠谱吗?直接将原函数的第一条指令修改为 jump 指令,会破坏掉原函数和它的调用者之间的寄存器上下文关系,存在安全隐患!本文会针对该问题进行探索和验证。安全性冲击:问题呈现对于函数调用,假设存在这样两个函数 funA 和 funB,其中 funA 调用 funB 函数,这里称 funA 为 caller(调用者),funB 为 callee(被调用者),funA 和 funB 都使用了相同的寄存器 R,如下所示:图1 funA 和 funB 都使用了寄存器 R,funA 再次使用 R 时已经被 funB 修改因此,当 funA 再次使用到 R 的数据已经是错误的数据了。如果 funA 在调用 funB 前保存寄存器 R 中的数据,funB 返回后再将数据恢复到 R 中,或者 funB 先保存 R 中原有的数据,然后在返回前恢复,就可以解决这类问题。唯一的调用约定那寄存器该由 caller 还是 callee 来保存?这就需要遵循函数的调用约定(call convention),不同的 ABI 和不同的平台,函数的调用约定是不一样的,对于 Linux 来说,它遵循的是 System V ABI 的 call convention,x86_64 平台下函数调用约定有且只有一种,调用者 caller 和被调用者 callee 需要对相应的寄存器进行保存和恢复操作:Caller-save registers : RDI, RSI, RDX, RCX, R8, R9, RAX, R10, R11Callee-save registers : RBX, RBP, R12, R13, R14, R15调用约定,gcc 它遵守了吗?设问:当函数实现很简单,只用到了少量寄存器,那没使用到的还需要保存吗?答案:it depends。根据编译选项决定。众所周知,GCC 编译器有 -O0、-O1、-O2 和 -Ox 等编译优化选项,优化范围和深度随 x 增大而增大(-O0是不优化,其中隐含的意思是,它会严格遵循 ABI 中的调用约定,对所有使用的寄存器进行保存和恢复)。Linux 内核选用的都是 -O2 优化。GCC 会选择性的不遵守调用约定,也就是设问里提到的,不需要保存没使用到的寄存器。当【运行时替换】撞见【调用约定】GCC 之所以可以做这个优化,是因为 GCC 高屋建瓴,了解程序的执行流。当它知道 callee,caller 的寄存器分配情况,就会大胆且安全地做各种优化。但是,运行时替换破坏了这个假设,GCC 所掌握的 callee 信息,极有可能是错误的。那么这些优化可能会引发严重问题。这里以一个具体的实例进行详细说明,这是一个用户态的例子( x86_64 平台)://test.c 文件 //编译命令:gcc test.c -o test -O2 (kernel 采用的是 O2 优化选项) //执行过程:./test //输入参数:4 #include <sys/mman.h> #include <string.h> #include <stdio.h> #include <math.h> #define noinline __attribute__ ((noinline)) //禁止内联 static noinline int c(int x) { return x * x * x; } static noinline int b(int x) { return x; } static noinline int newb(int x) { return c(x * 2) * x; } static noinline int a(int x) { int volatile tmp = b(x); // tmp = 8 ** 3 * 4 return x + tmp; // return 4(not 8) + tmp } int main(void) { int x; scanf("%d", &x); if (mprotect((void*)(((unsigned long)&b) & (~0xFFFF)), 15, PROT_WRITE | PROT_EXEC | PROT_READ)) { perror("mprotect"); return 1; } /* 利用 jump 指令将函数 b 替换为 newb 函数 */ ((char*)b)[0] = 0xe9; *(long*)((unsigned long)b + 1) = (unsigned long)&newb - (unsigned long)&b - 5; printf("%d", a(x)); return 0; }程序解释:该程序是对输入的数字进行计算,运行时利用 jump 指令将程序中的函数 b 替换为 newb 函数,即,将 y = x + x 计算过程替换为 y = x + (2x) ^ 3 * x;程序编译:gcc test.c -o test -O2,这里我们采用的是与编译内核相同的优化选项 -O2;程序执行:./test,输入参数:4,输出结果:2056;程序错误:2056 是错误的结果,应该是 2052,而且直接调用 newb 函数编译执行的结果是 2052。该例子说明,直接使用 jump 指令替换函数在 -O2 的编译优化下,会出现问题,安全性受到了质疑和冲击!!!安全性冲击:分析问题上述例子中,我们将函数 b 用 jump 指令替换为 newb 函数,在 -O2 的编译优化下出现了计算错误的结果,因此,我们需要对函数的调用执行过程进行仔细分析,挖掘问题所在。首先,我们先来查看一下该程序的反汇编(指令:objdump -d test),并重点关注 a、b 和 newb 函数:图2 -O2 编译优化的反汇编结果汇编解释:main:-> 将参数 4 存放到 edi 寄存器中-> 调用 a 函数:-> 调用 b 函数,直接跳转到 newb 函数:    -> 将 edi 寄存器中的值存放到 edx 寄存器    -> edi 寄存器与自身相加后结果放入 edi        -> 调用 c 函数:         -> 将 edi 寄存器中的值存到 eax 寄存器         -> edi 乘以 eax 后结果放入 eax         -> edi 乘以 eax 后结果放入 eax         -> 返回到 newb 函数    -> 将 edx 与 eax 相乘后结果放入 eax-> 返回到 a 函数-> 将 edi 与 eax 相加后结果放入 eax-> 返回 main 函数(注意:b 函数中没有对 edi 寄存器进行写操作,而且它的代码段被修改为 jump 指令跳转到 newb 函数)数据出错的原因在于,在函数 newb 中,使用到了 a 函数中使用的 edi 寄存器,edi 寄存器中的值在 newb 函数中被修改为 8,当 newb 函数返回后,edi 的值仍然是 8,a 函数继续使用了该值,因此,计算过程变为:8^3 * 4 + 8 = 2056,而正确的计算结果应该是 8^3 * 4 + 4 = 2052。接下来不进行编译优化(-O0),其输出结果是正确的 2052,反汇编如下所示:图3 不进行编译优化的反汇编从反汇编中可以看到,函数 a 在调用 b 函数前,将 edi 寄存器的值存在了栈上,调用之后,将栈上的数据再取出,最后进行相加。这就说明,-O2 优化选项将 edi 寄存器的保存和恢复操作优化掉了,而在调用约定中,edi 寄存器本就该属于 caller 进行保存/恢复的。至于为什么编译器会进行优化,我们此刻的猜想是:a 函数本来调用的是 b 函数,而且编译器知道 b 函数中没有使用到 edi 寄存器,因此调用者 a 函数没有对该寄存器进行保存和恢复操作。但是编译器不知道的是,在程序运行时,b 函数的代码段被动态修改,利用 jump 指令替换为 newb 函数,而在 newb 函数中对 edi 寄存器进行了数据读写操作,于是出现了错误。这是一个典型的没有保存 caller-save 寄存器导致数据出错的场景。而编译内核采用的也是 -O2 选项。如果将该场景应用到内核函数热替换是否会出现这类问题呢?于是,我们带着问题继续探索。安全性冲击:探索问题不再观察到 bug我们构造了一个内核函数热替换的实例,将上面的用户态的例子移植到我们构造的场景中,通过内核模块修改原函数的代码段,用 jump 指令直接替换原来的 b 函数。然而加载模块后,结果是正确的 2052,经过反汇编我们发现,内核中 a 函数对 edi 寄存器进行了保存操作:图4 内核中 a 函数的反汇编内核和模块编译时采用的是 -O2 优化选项,而此处 a 函数并没有被优化,仍然保存了 edi 寄存器。此时我们预测:对于内核函数的热替换来说,使用 jump 做函数替换是安全的。神奇的 -pg 选项我们猜想是否是内核编译时使用其它的编译选项导致问题不能复现。果不其然,经过探索我们发现内核编译使用的 -pg 选项导致问题不再复现。通过翻阅 GCC 手册得知,-pg 选项是为了支持 GNU 的 gprop 性能分析工具所引入的,它能在函数中增加一条 call mount 指令,去做一些分析工作。在内核中,如果开启了 CONFIG_FUNCTION_TRACER,则会使能 -pg 选项。图5 开启 CONFIG_FUNCTION_TRACER 使能 -pg 选项FUNCTION_TRACE 即我们常说的 ftrace 功能,ftrace 大大提升了内核的运行时调试能力。ftrace 功能除了 -pg 选项,还要求打开 -mfentry 选项,后者的作用是将函数对 mcount 的调用放到函数的第一条指令处,然后通scripts/recordmcount.pl 脚本将该条 call 指令修改为 nop 指令。但 -mfentry 与本文主题没有关联,不再细说。为了验证这个结论,我们回到上一节的用户态例子,并且增加了 -pg 编译选项:“gcc test.c -o test -O2 -pg”,此时运行结果果然正确了。查看其反汇编:图6 增加 -pg 选项后的汇编可以看到,每个函数都有 call mcount 指令,而且 a 函数中将 edi 寄存器保存到 ebx 中,在 newb 函数中又保存 ebx 寄存器。为什么在增加了 call mount 指令后,会做寄存器的保存操作?我们猜想,会不会是因为,由于 call mount 操作相当于调用了一个未知的函数( mcount 没有定义在同一个文件中),因此,GCC 认为这样未知的操作可能会污染了寄存器的数据,所以它才进行了保存现场的操作。于是我们去掉了 -pg 选项,手动增加了 call mount 的行为进行验证:在另一个源文件 mcount.c 中增加一个函数 void mcount() { asm("nop\n"); },在 test.c 文件中增加对 mcount 函数的声明,a 函数中增加对该函数的调用:extern void mcount(); //声明 mcount 函数 static noinline int a(int x){ int volatile tmp = b(x); // tmp = 8 ** 3 * 4 mcount(); return x + tmp; // return 4(not 8) + tmp }经过编译:gcc test.c mcount.c -O2 后运行,发现计算结果正确,而且反汇编中 a 函数保存了寄存器:图7 调用 mcount 函数后的汇编继续验证猜想,将 mcount 函数放在 test.c 文件中,计算结果错误,而且,反汇编中没有保存寄存器,于是我们得到了这样的猜想结论:GCC 在编译某个源文件时,如果文件内的某个函数(比如场景中的函数 a)调用了其它文件中的一个未知函数(比如场景中的 mcount 函数),则 GCC 会在该函数中保存寄存器;开启 -pg 选项,增加了对 mcount 的调用,因此会在函数中增加对寄存器现场的保存操作,对 -O2 选项的函数调用优化起到了屏蔽作用。神秘的 -fipa-ra 选项:真正的幕后主使经过我们的探索和资料的查阅,发现了这个 -fipa-ra 选项,可以说它是优化的幕后主使。GCC 手册中给出 -fipa-ra 选项的解释是:Use caller save registers for allocation if those registers are not used by any called function. In that case it is not necessary to save and restore them around calls. This is only possible if called functions are part of same compilation unit as current function and they are compiled before it. Enabled at levels -O2, -O3, -Os, however the option is disabled if generated code will be instrumented for profiling (-p, or -pg) or if callee’s register usage cannot be known exactly (this happens on targets that do not expose prologues and epilogues in RTL).这里主要是说,如果开启这个选项,那么,callee 中如果没有使用到 caller 使用的寄存器,就没有必要保存这些寄存器,前提是,callee 与 caller 在同一个编译单元中而且 callee 函数比 caller 先被编译,这样才可能出现前面的优化。如果开启了 -O2 及以上的编译优化选项,则会使能 -fipa-ra 选项,然而,如果开启了 -p 或者 -pg 这些选项,或者,无法明确 callee 所使用的寄存器,-fipa-ra 选项会被禁用。这段话,其实已经能 cover 掉我们前面大部分猜想的测试验证:-O2 选项自动使能 -fipa-ra 进行优化:在我们的场景中,函数 a 使用的 edi 寄存器,在函数 b 中没有使用到,因此函数 a 被优化,没有保存 edi 寄存器,但是在 newb 函数中,使用到了 edi 寄存器,且数据被修改,将 newb 函数替换函数 b,则计算结果出错;在 -O2 中使用 -pg 选项会禁用 -fipa-ra:编译时使用 -pg 选项,计算结果是正确的,而且函数 a 保存了 edi 寄存器,说明没有对函数 a 进行优化;不在同一编译单元不会被优化:去掉 -pg 选项,在函数 a 中手动调用 mcount 函数,将这个函数放在 test.c(与函数 a 为同一编译单元)与放在另一个文件 mcount.c(不同编译单元)中的计算结果不同:同一编译单元中计算结果是错误的,而且函数 a 没有保存寄存器现场;不在同一编译单元中,计算结果是正确的,函数 a(caller) 保存了寄存器现场,因为编译器无法明确函数 b(callee)所使用的寄存器。notrace:它是二度冲击吗?用过 ftrace 或者内核开发者应该对 notrace 属性不陌生,内核中有一些被 notrace 修饰的函数。notrace 其实就是给函数增加 no_instrument_function 属性。例如,在 X86 的定义:#define notrace __attribute__((no_instrument_function))字面上来看,notrace 和 -pg 的含义可以说完全对立,-pg 让 jump 变得安全,是否又会在 notrace 上栽一个跟斗呢?幸运的是,我们接下来将看到,notrace 仅仅是禁止了 instrument function,而没有破坏安全性。gcc 手册中的 -pg 选项给出这样的解释:Generate extra code to write profile information suitable for the analysis program prof (for -p) or gprof (for -pg). You must use this option when compiling the source files you want data about, and you must also use it when linking. You can use the function attribute no_instrument_function to suppress profiling of individual functions when compiling with these options.这里主要是说,加上 notrace 属性的函数,不会产生调用 mcount 的行为,那么,是否意味着不再保护寄存器现场,换句话说,notrace 的出现是否会绕过“-pg 选项对 -fipa-ra 优化的屏蔽”?于是我们又增加 notrace 属性进行验证:在 a 函数中增加 notrace 的属性,因为 a 函数是 caller,编译时开启 -pg 选项,然后检查计算结果及反汇编,最后发现,计算结果正确,而且汇编代码中保存了寄存器现场。图8 给 a 函数追加 notrace 属性,a 函数没有调用 mcount 的行为我们又对所有的函数追加了 notrace 属性,计算结果正确且寄存器现场被保护。但是这些简单的验证不足以证明,于是我们通过阅读 GCC 源码发现:图9 -pg 能禁用 -fipa_ra 选项图10 gcc 处理每一个函数时都会检查 -fipa-rq 选项,如果为 false,则不对函数进行优化通过源码阅读,可以确定的是,当使用了 -pg 选项后,会禁用 -fipa-rq 优化选项,GCC 检查每一个函数的时候都会检查该选项,如果为 false,则不会对该函数进行优化。由于 flag_ipa_ra 是一个全局选项,并不是函数粒度的,notrace 也无能为力。因此,这里可以排除对 notrace 的顾虑。安全性保障:得出结论经过上述的探索分析以及官方资料的查阅,我们可以得出结论:内核函数的热替换,利用 jump 指令直接跳转到新函数的方式是安全的;论据:Linux 遵循的 System V ABI 中的 call conversion 在 x86-64 下有且只有一种;GCC -fipa-ra 选项会对 call conversion 进行优化,-O2 选项会自动使能该选项,但是 -pg 选项会禁用 -fipa-ra 优化选项;notrace 属性无法绕过“ -pg 禁用 -fipa-ra”。ARM64 下的探索验证通过翻阅手册得知,ARMv8 ABI 中对过程调用时通用寄存器的使用准则如下(资料来源:https://developer.arm.com/documentation/den0024/a/The-ABI-for-ARM-64-bit-Architecture/Register-use-in-the-AArch64-Procedure-Call-Standard/Parameters-in-general-purpose-registers):Argument registers (X0-X7)These are used to pass parameters to a function and to return a result. They can be used as scratch registers or as caller-saved register variables that can hold intermediate values within a function, between calls to other functions. The fact that 8 registers are available for passing parameters reduces the need to spill parameters to the stack when compared with AArch32.Caller-saved temporary registers (X9-X15)If the caller requires the values in any of these registers to be preserved across a call to another function, the caller must save the affected registers in its own stack frame. They can be modified by the called subroutine without the need to save and restore them before returning to the caller.Callee-saved registers (X19-X29)These registers are saved in the callee frame. They can be modified by the called subroutine as long as they are saved and restored before returning.Registers with a special purpose (X8, X16-X18, X29, X30)X8 is the indirect result register. This is used to pass the address location of an indirect result, for example, where a function returns a large structure.X16 and X17 are IP0 and IP1, intra-procedure-call temporary registers. These can be used by call veneers and similar code, or as temporary registers for intermediate values between subroutine calls. They are corruptible by a function. Veneers are small pieces of code which are automatically inserted by the linker, for example when the branch target is out of range of the branch instruction.X18 is the platform register and is reserved for the use of platform ABIs. This is an additional temporary register on platforms that don't assign a special meaning to it.X29 is the frame pointer register (FP).X30 is the link register (LR).Figure 9.1 shows the 64-bit X registers. For more information on registers, see . For information on floating-point parameters, see Floating-point parameters.Figure 9.1. General-purpose register use in the ABI可见,ARMv8 ABI 中对函数调用时的寄存器使用有了明确的规定。我们对于前面 x86-64 下的探索验证过程在 arm64 平台下重新做了测试,相同的代码和相同的测试过程,得出的结论和 x86-64 下的结论是一致的,即,在 arm64 下,直接利用 jump 指令实现函数替换同样是安全的。其它场景的讨论其它语言不能保证其安全性对于 C 语言而言,在不同的架构和系统下都有固定的 ABI 和 calling conventions,但是其它的语言不能保证,比如 rust 语言,rust 自身并没有固定的 ABI,比如社区对 rust 定义 ABI 的讨论,而且 rustc 编译器的优化和 gcc 可能会有不同,因此可能也会出现上述 caller/callee-save 寄存器的问题。kpatch 的真面目kpatch 利用的是 ftrace 进行函数替换的,它的原理如下所示:图11 kpatch 利用 ftrace 替换函数ftrace 的主要作用是用来做 trace 的,会在函数头部或者尾部 hook 一个函数进行一些额外的处理,这些函数在运行过程中可能会污染被 trace 的函数的寄存器上下文,因此 ftrace 定义了一个 trampoline 进行寄存器的保存和恢复操作(图11 中的红框),这样从 hook 函数回来后,寄存器现场仍然是原来的模样。kpatch 用 ftrace 进行函数替换,hook 的函数是 kpatch 中的函数,该函数的作用是修改 regs 中的 ip 字段的值,也就是将新函数的地址给到了 ip 字段,等 trampoline 恢复寄存器现场后,就直接跳转到新的函数函数去执行了。所以,对于 kpatch 而言,ftrace 的保存和恢复现场操作保护的是 kpatch 中修改 ip 字段函数的过程,而不是它要替换的新函数。如果修复的是一个热函数,那么 ftrace 的 trampoline 会对性能产生一定的影响。所以,若考虑到性能的场景,那么使用 jump 指令直接替换函数可以很大的减少额外的性能开销。关于作者邓二伟(扶风),2020 年就职于阿里云操作系统内核研发团队,目前从事 linux 内核研发工作。吴一昊(丁缓),2017 年加入阿里云操作系统团队,主要经历有资源隔离、热升级、调度器 SLI 等。陈善佩(雏雁),高级技术专家,兴趣方向包括:体系结构、调度器、虚拟化、内存管理。讨论这么热烈,怎么能少了组织沉淀?Cloud Kernel SIG 盛情邀请你的加入云内核 (Cloud Kernel) 是一款定制优化版的内核产品,在 Cloud Kernel 中实现了若干针对云基础设施和产品而优化的特性和改进功能,旨在提高云端和云下客户的使用体验。与其他 Linux 内核产品类似,Cloud Kernel 理论上可以运行于几乎所有常见的 Linux 发行版中。在 2020 年,云内核项目加入 OpenAnolis 社区大家庭,OpenAnolis 是一个开源操作系统社区及系统软件创新平台,致力于通过开放的社区合作,推动软硬件及应用生态繁荣发展,共同构建云计算系统技术底座。扫码进群,侃侃而谈打开钉钉扫一扫哦
文章
Rust  ·  安全  ·  前端开发  ·  rax  ·  关系型数据库  ·  Linux  ·  编译器  ·  调度  ·  C语言  ·  虚拟化
2021-08-03
GO、Rust这些新一代高并发编程语言为何都极其讨厌共享内存?
今天我想再来讨论一下高并发的问题,我们看到最近以Rust、Go为代表的云原生、Serverless时代的语言,在设计高并发编程模式时往往都会首推管道机制,传统意义上并发控制的利器如互斥体或者信号量都不是太推荐。这里我们先来看一下并发和并行的概念,我们知道并发是一个处理器同时处理多个任务,这里同时是逻辑上的,而并行同一时刻多个物理器同时执行不同指令,这里的同时物理上的。并发是要尽量在目前正在执行的任务遇到阻塞或者等待操作时,释放CPU,让其它任务得以调度,而并行则是同时执行不同任务而不相互影响。而传统的信号量、互斥体的设计都是为了让单核CPU发挥出最大的性能,让程序在阻塞时释放CPU,通过控制共享变量的访问来达到避免冲突的目的,而想控制好这些共享变量的行为,其关键因此在于设计好时序,从本质上讲控制时序就是给系统加上红绿灯并配备路障,而这里你一定要记住,高性能系统需要的是立交桥、地下隧道这些基础设施,而不是交通信号等控制手段,好的并发系统一定要用流的概念来建模,而不是到处增加关卡路障。现在的处理都是多核架构,因此编程也要向并行倾斜,不过笔者在网上看到很多所谓标榜高并发教程中所举的例子,都把信号灯设计的时序很完美,却偏偏把立交桥全给扔了…..信号灯应该为导流服务,而不应为限流而生下面我们来看三段分别对应信号灯控制的操作,互斥体统治的“并发”,以及单纯的串行的代码,代码的目标其实就是要完成从0一直加到3000000的操作。信号灯控制其实这种信号量的代码已经基本退化回了顺序执行的方案了。正如我们在前文《GO看你犯错,但是Rust帮你排坑所说》,Rust的变量生命周期检查机制,并不能支持在不同线程之间共享内存,即便可以曲线救国,也绝非官方推荐,因此这里先用Go带各位读者说明。package main import ( "fmt" "sync" "time" ) var count int var wg1 sync.WaitGroup var wg2 sync.WaitGroup var wg3 sync.WaitGroup var wg4 sync.WaitGroup func goroutine1() { wg1.Wait() len := 1000000 for i := 0; i < len; i++ { count++ } wg2.Done() } func goroutine2() { wg2.Wait() len := 1000000 for i := 0; i < len; i++ { count++ } wg3.Done() } func goroutine3() { wg3.Wait() len := 1000000 for i := 0; i < len; i++ { count++ } wg4.Done() } func main() { now := time.Now().UnixNano() wg1.Add(1) wg2.Add(1) wg3.Add(1) wg4.Add(1) go goroutine1() go goroutine2() go goroutine3() wg1.Done() wg4.Wait() fmt.Println(time.Now().UnixNano() - now) fmt.Println(count) }在这里三个子协程goroutine,在4个信号量的控制下以多米诺骨牌的方式依次对于共享变量count进行操作,这段代码的运行结果如下:4984300 3000000 成功: 进程退出代码 0.互斥体控制与信号量完全退化成顺序执行不同,互斥体本质上同一时刻只能有一个goroutine执行到临界代码,但每个goroutine的执行顺序却无所谓,具体如下:package main import ( "fmt" "sync" "time" ) var count int var wg1 sync.WaitGroup var mutex sync.Mutex func goroutine1() { mutex.Lock() len := 1000000 for i := 0; i < len; i++ { count++ } mutex.Unlock() wg1.Done() } func main() { now := time.Now().UnixNano() wg1.Add(3) go goroutine1() go goroutine1() go goroutine1() wg1.Wait() fmt.Println(time.Now().UnixNano() - now) fmt.Println(count) }从运行实序上来看,互斥体的方案应该和信号量差不多,不过结果却令人意,在互斥体的控制下,这个程序性能反而还下降了30%,具体结果如下:5986800 3000000 成功: 进程退出代码 0.串行方式:最后用最返璞归真的做法,串行操作代码如下:package main import ( "fmt" //"sync" "time" ) var count int func goroutine1() { len := 1000000 for i := 0; i < len; i++ { count++ } } func main() { now := time.Now().UnixNano() goroutine1() goroutine1() goroutine1() fmt.Println(time.Now().UnixNano() - now) fmt.Println(count) }可以看到从效率上来讲,直接串行的方式和信号量的方式是差不多的,结果如下:4986700 3000000 成功: 进程退出代码 0.也就是说费了半天劲,最终结果可能还不如直接串行执行呢。Rust Future初探Rust中的future机制有点类似于 JavaScript 中的promise机制。Future机制让程序员可以使用同步代码的方式设计高并发的异步场景。目前虽然Go当中也有一些defer的机制,但远没有Rust中的future这么强大。Future机制将返回值value与其计算方式executor分离,从而让程序员可以不再关注于具体时序机制的设计,只需要指定Future执行所需要的条件,以及执行器即可。我们来看以下代码。注:cargo.toml[dependencies] futures = { version = "0.3.5", features = ["thread-pool"] }代码如下:use futures::channel::mpsc; use futures::executor; use futures::executor::ThreadPool; use futures::StreamExt; fn main() { let poolExecutor = ThreadPool::new().expect("Failed"); let (tx, rx) = mpsc::unbounded::<String>(); let future_values = async { let fut_tx_result = async move { let hello = String::from("hello world"); for c in hello .chars() { tx.unbounded_send(c.to_string()).expect("Failed to send"); } }; poolExecutor.spawn_ok(fut_tx_result); let future_values = rx .map(|v| v) .collect(); future_values.await }; let values: Vec<String> = executor::block_on(future_values); println!("Values={:?}", values); }上述代码中我们通过async指定了future_values ,并将这个Future指定给poolExecutor这个线程池执行,最后通过await方法,就可以让future全部执行完毕,而不必再用信号量控制具体的时序。这样一来,只要深度掌握future机制,就可以不必再关心互斥体、信号量,具体的高度方式完全放心交给计算机去做优化,不但可以节约程序员的时间,也能充分发挥编译器的威力,尾号是避免出现那种扔掉立交桥,只要信号灯低级的错误方式。Java虽然也有一定的Future实现,并且有Rust不具备的反射能力,但是冷起动一直是困扰Java的痛。因此在目前云原生的时代,Go和Rust尤其是Rust语言以其近首于C语言的启动速度,和运行效率真是很有可能在未来称王。
文章
Rust  ·  Cloud Native  ·  前端开发  ·  JavaScript  ·  Java  ·  程序员  ·  编译器  ·  Serverless  ·  Go  ·  C语言
2021-07-31
Go的闭包看你犯错,但Rust的lifetime却默默帮你排坑
闭包(Closure)在某些编程语言中也被称为 Lambda 表达式,是能够读取其他函数内部变量的函数。一般只有函数内部的子函数才能读取局部变量,所以闭包这样一个函数内部的函数,在本质上是将函数内部和函数外部连接起来的桥梁。在实践当中,假如我们需要统计一个函数被调用的次数,最简单的方式就是定义一个全局变量,每当目标函数被调用时就将此变量加1,但是全局变量会带来很多误用等问题,安全性往往得不到保证;而为调用次数专门设计一个以计数的接口又太小题大做了。但是通过闭包就比较容易实现计数功能,以Go语言为例具体代码及注释如下:​package main import ( "fmt" ) func SomeFunc() func() int { // 创建一个函数,返回一个闭包,闭包每次调用函数会对函数内部变量进行累加 var CallNum int = 0 //函数调用次数,系函数内部变量,外部无法访问,仅当函数被调用时进行累加 return func() int { // 返回一个闭包 CallNum++ //对value进行累加 //实现函数具体逻辑 return CallNum // 返回内部变量value的值 } } func main() { accumulator := SomeFunc() //使用accumulator变量接收一个闭包 // 累加计数并打印 fmt.Println("The first call CallNum is ", accumulator()) //运行结果为:The first call CallNum is 1 // 累加计数并打印 fmt.Println("The second call CallNum is ", accumulator()) //运行结果为:The second call CallNum is 2 }​运行结果为:​The first call CallNum is 1 The second call CallNum is 2​可以看到我们通过闭包即没有暴露CallNum这个变量,又实现了为函数计数的目的。Goroutine+闭包却出了莫名其妙的BUG在Go语言中,闭包所依托的匿名函数也是Goroutine所经常用到的方案之一,但是这两者一结合却容易出现极难排查的BUG,接下来我把出现问题的代码简化一下,请读者们来看下面这段代码:​import ( "fmt" "time" ) func main() { tests1ice := []int{1, 2, 3, 4, 5} for _, v := range tests1ice { go func() { fmt.Println(v) }() } time.Sleep(time.Millisecond) }​这段代码的逻辑不难看懂,其目标是通过Goroutine将1,2,3,4,5乱序输出到屏幕上,但最终执行结果却如下:​5 5 5 3 5 成功: 进程退出代码 0.​也就是只有大多数情况下只有5被输出出来了,1-4几乎没有什么机会登场,这里简要复述一下问题的排查过程,由于没有在Goroutine中对切片执行写操作,所以首先排除了内存屏障的问题,最终还是通过反编译查看汇编代码,发现Goroutine打印的变量v,其实是地址引用,Goroutine执行的时候变量v所在地址所对应的值已经发生了变化,汇编代码如下: ​for _, v := range tests1ice { 499224: 48 8d 05 f5 af 00 00 lea 0xaff5(%rip),%rax # 4a4220 <type.*+0xa220> 49922b: 48 89 04 24 mov %rax,(%rsp) 49922f: e8 8c 3a f7 ff callq 40ccc0 <runtime.newobject> 499234: 48 8b 44 24 08 mov 0x8(%rsp),%rax 499239: 48 89 44 24 48 mov %rax,0x48(%rsp) 49923e: 31 c9 xor %ecx,%ecx 499240: eb 3e jmp 499280 <main.main+0xc0> 499242: 48 89 4c 24 18 mov %rcx,0x18(%rsp) 499247: 48 8b 54 cc 20 mov 0x20(%rsp,%rcx,8),%rdx 49924c: 48 89 10 mov %rdx,(%rax) go func() { 49924f: c7 04 24 08 00 00 00 movl $0x8,(%rsp) 499256: 48 8d 15 f3 b7 02 00 lea 0x2b7f3(%rip),%rdx # 4c4a50 <go.func.*+0x6c> 49925d: 48 89 54 24 08 mov %rdx,0x8(%rsp) 499262: 48 89 44 24 10 mov %rax,0x10(%rsp) 499267: e8 54 3a fa ff callq 43ccc0 <runtime.newproc>​可Goroutine中fmt.Println所处理的v,其实是v的地址中所对应的值。这也是产生这个BUG的基本原因。找到了问题的原因,解决起来也就简单多了。解决方案一:在参数方式向匿名函数传递值引用,具体代码如下: package main import ( "fmt" "time" ) func main() { tests1ice := []int{1, 2, 3, 4, 5} for _, v := range tests1ice { go func(v int) { fmt.Println(v) }(v) } time.Sleep(time.Millisecond) } 解决方案二:在调用gorouinte前将变量进行值拷贝​package main import ( "fmt" "time" ) func main() { tests1ice := []int{1, 2, 3, 4, 5} for _, v := range tests1ice { w := v go func() { fmt.Println(w) }() } time.Sleep(time.Millisecond) }​总而言之只要传值就没事,而传地址引用就会出现问题。Rust为什么行利用周末时间我想看看上述问题代码在Rust的实现中是如何处理的,却有比较意外的收获,我们来看上述代码的Rust实现,​use std::thread; use std::time::Duration; fn main() { let arr = [1, 2, 3, 5, 5]; for i in arr.iter() { let handle = thread::spawn(move || { println!("{}", i); }); } thread::sleep(Duration::from_millis(10)); }​但是上述这段代码编译都无法通过,原因是arr这个变量的生命周期错配。具体编译结果如下:​error[E0597]: `arr` does not live long enough --> hello16.rs:6:14 | 6 | for i in arr.iter() { | ^^^ | | | borrowed value does not live long enough | cast requires that `arr` is borrowed for `'static` ... 13 | } | - `arr` dropped here while still borrowed error: aborting due to previous error; 1 warning emitted​我们刚刚提到过匿名函数其实是通过地址引用的方式来访问局部变量的,而地址引用也就对应Rust当中借用的概念,那么我们就可以推出来for i in arr.iter()中的 arr.iter()实际是对arr的借用,这个借用后的结果i被let handle = thread::spawn(move 中的move关键字强制转移走了,因此在handle线程离开作用域之后就被释放了,而下次迭代时arr变量由于lifetime的问题不能被编译器编译通过。为了更简要的说明这个问题我们来看下面的代码:​fn main() { { let x; { let y = 5; x = &y;// x借用y的值 } // y在这里已经被释放,因此借用y的x也不能通过lifetime检查 println!("x: {}", x); } }​x借用y的值,如果在y的lifetime以外,再出现x的访问就会出现问题。如果想避免这个问题就不能再使用借用的机制,可以编译通过的代码如下:​use std::thread; use std::time::Duration; fn main() { let arr = [1, 2, 3, 5, 5]; for i in arr.iter() {//这段代码中i是对arr的借用 let j=i+1;//j通过值拷贝的方式获取了i的值 let handle = thread::spawn(move || {//move将j强制转移给了handle println!("{}", j); });//这里j超出lifetime就不会影响到i了 } thread::sleep(Duration::from_millis(10)); }​新添加的let j=i+1;是通过值拷贝的方式将i和j剥离了,因此j在被释放的时候就不会影响到arr的借用i了。凡是编译器能发现的错误,都不会形成问题。通过这个Go语言问题的排查,我对于Rust的变量生命周期检查机制有了更进一步的认识,不得不承认虽然Rust学起来比较劝退,但是其安全语言的名号真是所言不虚,强制让程序员做正确事,如果能知其然又知其所以然,那么提升将是巨大的。
文章
Rust  ·  前端开发  ·  rax  ·  网络协议  ·  安全  ·  编译器  ·  程序员  ·  Go
2021-07-24
Java、Rust、Go主流编程语言的哈希表比较——《我的Java打怪日记》
哈希表(HashMap、字典)是日常编程当中所经常用到的一种数据结构,程序员经常接解到的大数据Hadoop技术栈、Redis缓存数据库等等最近热度很高的技术,其实都是对键值(key-value)数据的高效存储与提取,而key-value恰恰就是哈希表中存储的元素结构,可以说Redis、HDFS这些都是哈希表的经典应用,不过笔者之前也只知道哈希表比较快,但对于具体什么场景下快,怎么用才快等等知识却一知半解,因此这里把目前的一些研究成果分享给大家。重新认识哈希表所谓的哈希表就是通过哈希算法快速搜索查询元素的方法,比如说你要在茫茫人海当中找到一位笔名叫做beyondma的博主,但却并不知道他具体的博客地址,在这种情况下就只能在所有的博主范围内展开逐个的排查与摸索,运气差的话我可能以找遍所有n个博主的主页,才到beyondma,这也就是这种遍历查找的时间复杂度是o(n),查找的时间会随着博主的数量而线增长。而哈希算法就是直接将beyondma这个名字进行算法处理,直接得到beyondma的博客地址信息,在哈希算法的加持下定位某一元素的时间度变成了o(1),由于哈希算法能够将key(键值本例中指beyond)和value(本例中指beyond.csdn.net)以o(1)的时间复杂度,直接对应起来,因此哈希表被人称为key-value表,存储的元素也被称为key-value对(键值对)。哈希表的查找过程特别像查字典,给出一个字并找到这个字在字典中的位置,只是哈希表在一般情况下都很快。当然哈希表也有代价:以空间换时间:哈希算法也称为散列算法,这种叫法相对比较直观,由于哈希算法是通过计算确认存储地址的,因此首先进入到哈希表的元素并不一定存到第一个位置,存储n个键值对的哈希表往往会消耗比切片多很多的内存空间。哈希碰撞:哈希碰撞是指不同的键值,在经过哈希计算后得到的内存地址槽位是相同的,也就是说相同的地址上要存储两个以上的键值对,一旦发生这种情况,也就是哈希碰撞了。在发生碰撞的场景下哈希表会进行退化,其中Java会在碰撞强度到达一定级别后,会使用红黑树的方式来进行哈希键值对的存储,而Go和Rust一般都是退化成为链表。下面我们首先来详细讲讲两个哈希表的常见误用。哈希表的误用不要遍历哈希表!:局部快,不意味着整体快,由于哈希表提取单个元素的速度很快,因此整个遍历整个集合所需要的时间也会更短,这种看法明显是个美丽的误会。我们后文也会具体讲到,哈希表在遍历方面的表现结果,是由计算机组成原理决定的,与Go、Rust和Java的区别不大,因此以下例子先以Go语言的代码为例来说明。​package main​​​​​import (​​ "fmt"​​ "time"​​)​​​​​func main() {​​ testmap := make(map[int]int)​​ len := 1000000​​ //tests1ice := make([]int, len, len)​​ for i := 0; i < len; i++ {​​ testmap[i] = i + 1​​ }​​ sum := 0​​ now := time.Now().UnixNano()​​ for k, v := range testmap {​​ sum = sum + k + v​​ }​​ diff := time.Now().UnixNano() - now​​ fmt.Println("sum=", sum)​​ fmt.Println("diff=", diff)​​ // fmt PrintIn("slice=", slice)​​​​​} ​​​​可以看到使用哈希表进行遍历的话,以上代码运行的结果为:​ sum= 1000000000000 ​​ diff= 29297200 ​​成功: 进程退出代码 0.​​ 而对比使用切片遍历的代码如下:​ package main​​​​​import (​​ "fmt"​​ "time"​​)​​​​​func main() {​​ //testmap := make(map[int]int)​​ len := 1000000​​ tests1ice := make([]int, len, len)​​ for i := 0; i < len; i++ {​​ tests1ice[i] = i + 1​​ }​​ sum := 0​​ now := time.Now().UnixNano()​​ for k, v := range tests1ice {​​ sum = sum + k + v​​ }​​ diff := time.Now().UnixNano() - now​​ fmt.Println("sum=", sum)​​ fmt.Println("diff=", diff)​​ // fmt PrintIn("slice=", slice)​​​​​} ​以上代码运行结果为:​ sum= 1000000000000 ​​ diff= 1953900 ​​成功: 进程退出代码 0.​​ 可以看到同样长度的集合遍历性能表现,切片的耗时只有哈希表的5%左右,两者几乎相差两个数量级。数据访问局部性原理的制约:局部性原理可能是计算机基本原理中威力最强的基本定理之一,也是程序员在编程过程中必须要考虑的规律,因此我们看到在计算机世界中局部性原理,经常在速度不匹配的存储介质中得到运用,比如英特尔的CPU往往分为三级高速缓存,彼此之间的速度差距大概在8到10倍之间,其中高速缓存中的第三级缓存又比内存快10倍,这样彼此之间各差10倍左右的缓存体系加速效果最好,这就像军事行动中,先锋部队既要率先行动,又不能与大部队过于脱节,才能圆满的完成任务。在实际CPU的工作当中,如果数据单元A1被访问了,那么A1的邻居A0和A2被访问到的可能性也会极大的增加,因此CPU一般都会在数据单元A1被访问的同时,将他的邻居们调入高速缓存。也就是说切片这种在内存当中连续分布的数据结构,其元素都是以高速缓存行的大小为单位读入到高速缓存的,而高速缓存的平均速度又是内存的几十倍,因此相当于一次读取操作,就能快速处理好几个元素;但由于哈希表实际也是稀疏表,一个键值对的周围可能没有其它有效键值对,因此哈希表在遍历时实际上只能一个一个元素的处理。这样比较下来哈希表在单个元素的访问上快,但在整体遍历上慢也就不足为奇了。在元素不多不要用哈希表!我经常看到有不少程序员在元素不多的情况下,还坚持使用哈希表来建立key-value的对应关系,其实这样的做法并不会带来效率的提升,正如我们刚刚所说,哈希算法也被称为散列算法,键值对的内存地址分布很可能并不连续,这就特别不方便局部性原理发挥作用。刚刚我们上文也提到了内存缓存行的大小通常是64byte,在实际测试过程中可以看到如果元素能在一个内存缓存行存储下来,就不要用哈希表了,这时候用数据切片,每次遍历查找的性能反而比哈希表更快。具体代码如下:哈希表实现示例​package main​​​​​import (​​ "fmt"​​ "time"​​)​​​​​func main() {​​ testmap := make(map[int]int)​​ len := 10​​ times := 100000​​ //tests1ice := make([]int, len, len)​​ for i := 0; i < len; i++ {​​ testmap[i] = i + 1​​ }​​ sum := 0​​ now := time.Now().UnixNano()​​ for i := 0; i < times; i++ {​​ //for k, v := range testmap {​​ //if i%len == v {​​ sum = sum + i%len + testmap[i%len]​​ //break​​ //}​​ //}​​​​​ //sum = sum + k + v​​ //tests1ice[i%len] = i + 1​​ }​​​​​ diff := time.Now().UnixNano() - now​​ fmt.Println("sum=", sum)​​ fmt.Println("diff=", diff)​​ // fmt PrintIn("slice=", slice)​​​​​}​以上代码结果如下:​ sum= 1000000 ​​diff= 2929500​​ 而切片遍历查找的实现如下:​package main​​​​​import (​​ "fmt"​​ "time"​​)​​​​​func main() {​​ //testmap := make(map[int]int)​​ len := 10​​ times := 100000​​ tests1ice := make([]int, len, len)​​ for i := 0; i < len; i++ {​​ tests1ice[i] = i + 1​​ }​​ sum := 0​​ now := time.Now().UnixNano()​​ for i := 0; i < times; i++ {​​ for k, v := range tests1ice {​​ if i%len == k {​​ sum = sum + k + v​​ break​​ }​​ }​​​​​ //sum = sum + k + v​​ //tests1ice[i%len] = i + 1​​ }​​​​​ diff := time.Now().UnixNano() - now​​ fmt.Println("sum=", sum)​​ fmt.Println("diff=", diff)​​ // fmt PrintIn("slice=", slice)​​​​​}​​ ​ sum= 810000 ​​diff= 1953000​​ 成功: 进程退出代码 0.​​ 少元素方面集合的元素定位性能上,哈希表比切片慢了40%,当然这也是局部性原理造成的,由于元素比较少,因此切片这样内存连续数据结构,完全可以在高速缓存中完成数据的查找定位,这样综合下来其性能反而还要比哈希表要快。正如前文所述,哈希算法的工作机制本身就决定了哈希表对存储空间就有一定的浪费,因此在没有性能优势的情况下,尤其是上述遍历及短表的场景下,就不要再用哈希表了,完全没有必要。哈希表的实现机制要点在笔者看了部分哈希表的代码之后,Java、Go和Rust这三种语言有一些相同的机制,也有一些不同,其中有两点值得关注,当然由于水平有限,如有错误之处敬请指正。避免使用连续内存块:我们知道在内存、硬盘等存储设备的管理中,连续的空间往往是比较宝贵的,而哈希表是相对比较稀疏的数据结构,因此Java、Go和Rust基本都引用了一些比如桶的机制,尽量避免占用连续的内存块。以Go语言的实现为例:​type hmap struct {​​ count int // map的长度​​ flags uint8​​ B uint8 // map中的bucket的数量,​​ noverflow uint16 // ​​ hash0 uint32 // hash 种子​​​​​ buckets unsafe.Pointer // 指向桶的指针​​ oldbuckets unsafe.Pointer // 指向旧桶的指针,这里用于溢出​​ nevacuate uintptr ​​ extra *mapextra // optional fields​​}​​​​​// 在桶溢出的时候会用到extra​​type mapextra struct {​​ overflow *[]*bmap​​ oldoverflow *[]*bmap​​ nextOverflow *bmap​​}​​​​​type bmap struct {​​ tophash [bucketCnt]uint8// Map中的哈希值的高8位为桶的地址​​​​​}​​ 在访问Map中的键值对时,需要先计算key的哈希值,其中哈希的值的低8位定位到具体的桶(bucket),通过高8位在桶内定位到具体的位置,而不同桶之间所占用的内存区域也不需要是连续的空间,这样也就从一定程度上弥补哈希表占用空间较大的缺点。哈希碰撞处理:我们刚刚也介绍了哈希表碰撞的内容,也就是出现了不同的键值对要存储在同一个内存槽位的场景,极端情况下是所有键值对全部发生碰撞,这样哈希表实际也就退化成了链表,Java对碰撞的处理相对比较成熟,如果退化的链表长度大于8,那么Java会选择用红黑树这种近似于二叉排序树的数据结构进行替代,从而保证定位性能不低于O(logn)而如果链表的长度小于等于8,那么如我们上文介绍,在数据长度比较短的情况下其实链表的性能可能还会更好,没必要使用引入红黑树,由此可见Java这门语言的确已经非常成熟。
文章
存储  ·  缓存  ·  Rust  ·  分布式计算  ·  算法  ·  NoSQL  ·  Java  ·  程序员  ·  Go  ·  Redis
2021-07-17
WebAssembly 在 MOSN 中的实践 - 基础框架篇
作者:叶永杰来源:金融级分布式架构公众号本文将介绍 WebAssembly 技术在 MOSN 中的实践,首先介绍了当前 MOSN 在扩展隔离方面所面临的痛点,并对 Wasm 技术的相关背景知识进行介绍。随后描述了Wasm 扩展框架的整体架构,并介绍了我们在 Proxy-Wasm 社区规范中所做的贡献,最后描述了框架在性能、异常调试等方面的实践内容。作为金融级服务网格中的流量代理组件,MOSN 在承载蚂蚁数十万服务容器之间流量的同时,也承载着诸多例如限流、鉴权、路由等中间件基础能力。这些能力以不同的扩展形式与 MOSN 运行于同一进程内。非隔离的运行方式在保障性能的同时,却也给 MOSN 带来了不可预知的安全风险。针对上述问题,我们采用 WebAssembly(Wasm) 技术,给 MOSN 实现了一个安全隔离的沙箱环境,让扩展程序能够运行在隔离沙箱之中,并对其资源、能力进行严格限制,使程序故障止步于沙箱,从而实现安全隔离的目标。本文将着重叙述 MOSN 中的 Wasm 扩展框架,并介绍我们在 Proxy-Wasm 这一开源规范上的贡献。总体设计上图为 MOSN Wasm 扩展框架的整体示意图。如图所示,对于 MOSN 的任意扩展点(Codec、NetworkFilter、StreamFilter 等),用户均能够通过 Wasm 扩展框架,以隔离沙箱的形式运行自定义的扩展代码。而 MOSN 与 Wasm 扩展代码之间的交互,则是通过 Proxy-Wasm 标准 ABI 来完成的。隔离沙箱当我们在讨论 Wasm 时,都明白 Wasm 能够提供一个安全隔离的沙箱环境,但并不是每个人都了解 Wasm 实现隔离沙箱的技术原理。这时又要搬出计算机科学中的至理名言: “计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。Wasm 实际上也是通过引用一个“中间层”来实现的安全隔离。简单来说,Wasm 通过一个运行时 (Runtime) 来运行 Wasm 沙箱扩展,每个 Wasm 沙箱都有其独立的线性内存空间和一组导入/导出模块。一方面,每个 Wasm 沙箱都有其独立的线性内存空间,其内存模型如上图所示。Wasm 代码只能通过简单的 load/store 等指令访问线性内存空间的有限部分,并通过符号(下标)的方式来间接访问函数、全局变量等。上述限制杜绝了类似 C 语言中访问任意内存地址的骚操作。同时,用于间接调用函数的符号表对于 Wasm 代码而言是只读的,保证了 Wasm 代码的执行是受控的。此外,Wasm 沙箱的整个线性内存空间由宿主机 (Wasm Runtime) 分配及管理,通过严格的内存管理保证沙箱的隔离性。另一方面,Wasm 也规定了代码中任何可能产生外部影响的操作只能通过导入/导出模块来实现。当我们在编写 C 语言源码时,可以直接通过系统调用来访问系统的环境变量、文件、网络等资源。而在 Wasm 的世界中,并不存在系统调用相关的指令,任何对外部资源的访问必须通过导入模块来间接实现。以文件读写为例,在 Wasm 中要想进行文件读写,需要宿主机提供实现文件读写功能的导入函数,Wasm 代码调用该导入函数,由宿主机间接进行文件读写,再将操作结果返回给 Wasm 扩展。在上述过程中,实际的文件读写操作由宿主机完成,宿主机对这一过程有绝对的控制权,包括但不限于只允许读写指定文件、限制读写内容、完全禁止读写等。扩展框架MOSN 以 插件(Plugin) 的形式对 Wasm 扩展进行统一管理,插件是指一组 Wasm 沙箱实例及其配置的集合。用户通过配置来加载、更新以及卸载 Wasm 插件,并通过配置来描述沙箱实例的运行规格(使用的执行引擎、Wasm 文件路径、实例数量等)。下面展示了一个典型的 Wasm 插件配置:当 MOSN 加载上述插件配置时,会按照以下流程生成插件对应的 Wasm 沙箱实例:在后续运行的过程中,用户通过 Wasm 扩展框架获取指定插件的沙箱实例, 然后通过沙箱实例暴露的 API 与扩展程序进行交互。本文的下一小节将对此交互过程进行详细描述。在 MOSN 中,Wasm 扩展框架与具体用途无关,在 MOSN 已有的任何一处扩展点,均可以直接使用 Wasm 框架来获取安全隔离的插件执行能力。如下图所示,Wasm 扩展框架主要分为 Manager、VM 和 ABI 三个子模块。其中Manager 模块负责对 Wasm 插件的配置进行统一管理,提供插件的增删查改功能,并负责将用户提供的插件配置渲染成一组的 Wasm 沙箱实例VM 模块提供对 Wasm Runtime(虚拟机) 的统一封装,负责 .wasm 文件的编译、执行,以及 Wasm 沙箱实例的资源管理ABI 模块则提供对外的使用接口,可以看作是 MOSN 与 Wasm 扩展代码之间交互的胶水层本文不再对框架的具体实现细节进行介绍,感兴趣的读者可以阅读开源 PR 文档了解细节。由于当前市面上几乎不存在使用 Go 语言直接编写的 Wasm Runtime,因此 MOSN 只能通过 CGO 调用的方式来间接地调用由 C++/Rust 编写的 Wasm 执行引擎。我们从 SDK 完善程度、性能、项目活跃度等角度综合考虑,经过一系列横向对比之后,选择了 Wasmer 作为 MOSN 默认的执行引擎。Proxy-Wasm ABI 规范本小节将介绍 MOSN 具体是如何跟 Wasm 扩展程序进行交互的。先说结论: MOSN 跟 Wasm 扩展代码之间的交互采用的是社区规范: Proxy-Wasm。Proxy-Wasm 是开源社区针对「网络代理场景」设计的一套 ABI 规范,属于当前的事实规范。当前支持该规范的网络代理软件包括 Envoy、MOSN 和 ATS(Apache Traffic Server),支持该规范的 Wasm 扩展 SDK 包括 C++、Rust 和 Go。采用该规范的好处在于能让 MOSN 复用社区既有的 Wasm 扩展 (包括 Go 实现以及 C++/Rust 实现),也能让原本专门为 MOSN 开发的 Wasm 扩展运行在 Envoy 等网络代理产品上。Proxy-Wasm 规范定义了宿主机与 Wasm 扩展程序之间的交互细节,包括 API 列表、函数调用规范以及数据传输规范这几个方面。其中,API 列表包含了 L4/L7、property、metrics、日志等方面的扩展点,涵盖了网络代理场景下所需的大部分交互点,且可以划分为宿主侧扩展和 Wasm 侧扩展。这里简单展示规范中的部分内容,完整内容请参考 spec。规范的实现需要宿主侧和 Wasm 侧两边配合才能正常工作。对于 Wasm 侧,社区已经有 C++、Rust 和 Go 三种语言实现的 SDK,用户可以直接使用这些 SDK 来编写与宿主无关的 Wasm 扩展程序。而对于宿主侧,社区只提供了 C++ 和 Rust 的宿主侧实现。为此,我们在项目中使用 Go 语言对 Proxy-Wasm 规范的宿主侧进行了实现,并将其贡献给开源社区,使之成为社区推荐的 Go-Host 实现 (如下图所示)。需要强调的是,宿主侧实现并不依赖具体的网络代理程序,理论上任何直接通过 Host 程序与 Wasm 扩展进行交互。我们以 HTTP 场景为例,介绍在 MOSN 中是如何通过 Proxy-Wasm 规范来与 Wasm 扩展程序进行交互,处理 HTTP 请求的。MOSN 收到 HTTP 请求时,将请求解码成 Header、Body、Trailer 三元组结构,按照配置依次执行 StreamFilters。执行到 Wasm StreamFilter 时,MOSN 将请求三元组传递给 Proxy-Wasm 宿主侧实现 proxy-wasm-go-host。宿主侧 go-host 将 MOSN 请求三元组编码成规范指定的格式,并调用规范中的 proxy_on_request_headers 等接口,将请求信息传递至 Wasm 侧。Wasm 侧 SDK 将请求数据从规范格式转换为便于用户使用的格式,随后调用用户编写的扩展代码。用户代码返回,Wasm 侧将返回结果按规范格式传递回 MOSN 侧。MOSN 继续执行后续 StreamFilter。上述示例中,我们并不限制 Wasm 侧的语言实现,用户可以使用 C++/Rust/Go 几种语言来编写自定义的扩展代码。与之相对的,只需要用相应语言的 Proxy-Wasm-SDK 一起编译成 .wasm 文件,即可运行在 MOSN 之上。工程实践Quick Start本小节主要演示如何在 MOSN 中进行配置并运行 Wasm 扩展插件流程。演示所需的源文件参考 example。在演示中,我们通过配置让 Wasm 扩展插件来处理 MOSN 接收的 HTTP 请求,MOSN 的监听端口为 2045。在 Wasm 处理请求的源码中,我们通过 Proxy-Wasm 规范中的 proxy_dispatch_http_call 接口向外部 HTTP 服务器发起请求,Wasm 源码内指定外部 HTTP 服务器的监听端口为 2046。演示场景的流程如下图所示:该演示流程主要分为以下步骤:将扩展程序编译成 .wasm 文件启动 MOSN 并加载 Wasm 插件启动外部 HTTP 服务器请求验证1.编译 Wasm 扩展程序我们在示例工程中提供了 C 和 Go 两种语言实现的 Wasm 扩展源码,对 Proxy-Wasm 规范的采用使得我们能够利用多种语言 (C++/Rust/Go) 来编写 Wasm 扩展代码。出于编译的便利性,这里使用 Go 源码实现进行演示。进入 example/wasm/httpCall 目录,执行命令:make上述操作会将目录下的 filter-go.go 源码文件编译成 filter-go.wasm 文件2.启动 MOSN示例工程提供了一份加载 filter-go.wasm 扩展文件的配置,通过以下命令即可启动:./mosn start -c config.json上述命令中使用的 MOSN 可执行程序可以通过以下命令由源码构建:3.启动外部 HTTP 服务器该示例工程中,Wasm 扩展源码会通过 MOSN 向外部 HTTP 服务器发起请求,请求的 URL 为:http://127.0.0.1:2046/为此,示例工程也提供了一段 HTTP 服务器代码,当其收到 HTTP 请求时,均会返回响应头: from: external http server,返回响应体: response body from external http server。执行以下命令将启动上述 HTTP 服务器:go run server.go4.请求验证上述操作准备就绪后,便可通过 Curl 来进行请求验证了。curl -v http://127.0.0.1:2045/执行上述命令后,MOSN 终端将能够观察到以下日志:性能测试测试环境:OS: macOS Catalina 10.15.4CPU: Intel(R) Core(TM) i7-7660U CPU @ 2.50GHz 4CoreMEM: 16 GB 2133 MHz LPDDR3Go Version: go1.14.13 darwin/amd64测试场景:拓扑: client --http1.1--> MOSN操作: MOSN 收到 H1 请求后,往请求头中添加一个 Header 随后返回 200测试数据:「native」表示添加 Header 的操作使用 MOSN 原生的 Stream Filter 完成;「wasm」表示添加 Header 的操作使用 Wasm 扩展完成固定 QPS 模式,将 QPS 固定为 2000 进行压测压测命令: sofaload --h1 -c 100 -t 4 --qps=2000 -D 30 http://127.0.0.1:2045/压测模式,不限制压测 QPS,将流量打到最大压测命令: sofaload --h1 -c 100 -t 4 -n 1000000 http://127.0.0.1:2045/异常调试对于实际的工程项目而言,光能运行是不够的,必须具备一定的问题排查和定位能力,才能在遇到程序故障时,解析异常源码的调用堆栈,快速定位第一现场,从而提高开发及调试的效率。由于 Wasm 本身的定位是与编程语言无关的字节码规范,不同语言的源代码 (C++/Go/JavaScript 等) 均能够编译为统一的 Wasm 字节码,因此如何屏蔽具体编程语言的细节模型,制定语言无关的调试信息规范,是社区需要解决的难题之一。针对这一问题,在当前的工程实践中,JavaScript 语言采用的是 Source Map 格式,而 C++、Rust 和 Go 语言采用的是 Dwarf 格式的调试信息。对具体调试信息格式的介绍并不在本文的范围之内,读者可自行参考外部文章。这里需要强调的是,对于 Wasm 而言,还需要对调试信息的格式进行一定的扩展,才能满足实际的应用需要。与其他编程语言不同的是,.wasm 文件是能够被转换成 .wat 格式,并手动编辑内容的,编译好的 .wasm 文件仍然有修改段内容的可能。为了适应这种场景,Wasm 调试规范对 Dwarf 格式中的位置信息编码进行了调整,指令的偏移值被设置成基于 Code 段的偏移:With WebAssembly, the .debug_line section maps Code section-relative instruction offsets to source locations.为此,我们在解析指令偏移时,需要偏移数值进行调整,减去 Code 段的偏移量,才能得到 Wasm 指令的实际偏移值,进而利用 .debug_line 段定位到准确的源码行。下图展示了利用 MOSN 输出的错误日志定位 Wasm 故障源码行的示例。协议编解码能力: https://www.atatech.org/articles/199319编解码插件开发指南:https://www.atatech.org/articles/200651总结对于蚂蚁而言,安全可信永远是我们追求的目标,而面对越来越多的扩展场景,MOSN 需要一个安全可靠的隔离环境,以避免扩展代码给 MOSN 运行造成的安全风险。为此,我们采用 WebAssembly 技术,为 MOSN 实现了一个基于 Wasm 隔离沙箱的插件扩展框架。MOSN 采用网络代理社区中的 Proxy-Wasm 规范,实现了语言无关、宿主无关的网络代理扩展能力。同时,我们也向开源社区贡献了 Proxy-Wasm-Go-Host 实现,积极融入开源社区。需要注意的是,当前 WebAssembly 技术仍处于发展阶段,Go 语言自身对 WebAssenbly 生态的支持仍有巨大的提升空间。我们在实践的过程中,也总是面临 Go 语言在 Wasm 生态中不够给力的情况。由于 Go 官方编译器还不支持将 Go 源码程序编译成 WASI 系统接口 (GOOS=wasi) 的 .wasm 文件,我们不得不借助 TinyGo 来完成 Go 扩展程序的编译,而这也导致我们需要面对 TinyGo 在语言特性支持程度、性能、稳定性等方面不足的痛点。与之相比,C++/Rust 对 Wasm 生态的支持程度就要完善得多。总而言之,WebAssembly 技术的出现仍然为我们提供了一种启发和希望,促使我们进一步思考如何在云原生时代更好地践行安全可信这一信条。延伸阅读在一个“追求极致稳定性”的团队工作,是什么体验?SOFAGW 网关:安全可信的跨域 RPC/消息 互通解决方案MOSN 的无人值守变更实践蚂蚁 ServMesh 大规模落地实践与展望
文章
编解码  ·  Rust  ·  安全  ·  JavaScript  ·  前端开发  ·  测试技术  ·  Go  ·  API  ·  开发工具  ·  C++
2021-07-13
Rust 大展拳脚的新兴领域:机密计算
来源:金融级分布式架构公众号云原生时代,Go 语言凭借其原生支持高并发等特性,一跃成为云原生基层设施建设首选语言,荣登各大编程语言排行榜前列,成为用户增长最快的新兴编程语言之一。而相比之下,同样以取代 C/C++为目的被创造出来的另一门新语言 Rust 在很长一段时间里则不温不火,陷入“叫好不叫座”的尴尬境地。幸运的是,随着云原生浪潮逐渐进入下半场,企业和用户对数据的安全性有了更高的要求,“可信原生”与“机密计算”等数据安全新概念被业界提出,并吸引了大批软硬件巨头入场布局, 也让 Rust 这门注重内存安全的高性能编程语言迎来了绝佳的发展机遇。深耕金融、支付相关技术领域的蚂蚁集团是国内外机密计算技术领域的先行者,其开源的 SOFAEnclave 机密计算解决方案被包括微软在内的多家科技巨头采用,团队输出的相关技术论文也多次在国际顶会发表,受到工业界和学术界的广泛认可。为深入了解这一前沿技术领域,我们邀请到了蚂蚁集团机密计算总监闫守孟,为我们揭开机密计算的神秘面纱。什么是机密计算?随着云计算的快速发展,越来越多的关键性服务和高价值数据被迁移到了云端。云安全也因此成为学术界和工业界关注的一个焦点。机密计算填补了当前云安全的一项空白——使用中数据(Data-in-use)的加密。过去通行的做法是对数据在存储中(比如硬盘)和传输中(比如网络)加密,而在使用中(比如内存)解密,以便处理。而机密计算可以保护使用中数据的机密性和完整性。也就是说,通过机密计算技术,一些对数据安全性要求极高的传统企业如金融、银行、政企等客户,也可以放心地使用公有云服务。目前,多家云计算巨头都在不约而同地推广这项技术:微软已于 2017 年 7 月宣布开始接受 Azure 机密计算的早期试用申请;IBM 于 2017 年 12 月宣布 IBM 云数据保护(Cloud Data Guard)的预览版;谷歌也于 2018 年 5 月开源了名为 Asylo 的机密计算框架。2019 年 8 月,Linux 基金会宣布联合多家科技巨头组建“机密计算联盟(Confidential Computing Consortium,简称 CCC)”,创始成员包括阿里巴巴、Arm、百度、谷歌、IBM、英特尔、微软、红帽、瑞士通以及腾讯。让机密计算技术进入了更多开发者的视野。那么,机密计算究竟是如何实现的呢?实际上,上述所有云计算巨头在实现机密计算时都离不开一种称为“可信执行环境(TEE)”的技术。顾名思义,TEE 提供一种与不可信环境隔离的安全计算环境,正是这种隔离和可信验证机制使得机密计算成为可能。TEE 一般是直接基于硬件实现的,比如 Intel SGX,Intel TDX,AMD SEV,ARM TrustZone,以及 RISC-V Keystone 等;基于虚拟化技术也可以构造 TEE,比如微软的 VSM,Intel 的 Trusty for iKGT & ACRN。其中,Intel 软件防护拓展(Software Guard Extensions,简称 SGX)是目前商用 CPU 中最为先进的 TEE 实现,它提供了一套新的指令集使得用户可以定义称为 Enclave 的安全内存区域。CPU 保证 Enclave 与外界强隔离,并提供内存加密和远程证明机制,从而保护 Enclave 代码和数据的机密性、完整性和可验证性。不同于之前的 TEE 实现,比如 ARM TrustZone,SGX 每个 APP 都可以有自己独立的 TEE,甚至可以创建多个 TEE,而 TrustZone 是整个系统有一个 TEE;这里也省去了向设备厂商申请将 TA(可信应用)装入 TEE 的过程。由于 SGX 的先进性,目前云端机密计算领域甚至已公认用 Enclave 这个词来指代 TEE。通过机密计算技术,能够解决那些业务数据敏感的用户对云原生平台不信任的问题,也由此引出了“可信原生”的概念,即让云原生基础设施在用户一侧更加可信。机密计算应用开发难点显然,这一技术的成熟意味着包括金融、政企等业务数据敏感的用户上云成为可能,市场前景广阔。虽然这些听起来很美,但机密计算在实际应用中仍面临很多的挑战。首先,Enclave 是一个受限的环境,从编程接口到编程模型都跟开发者熟悉的 Linux 环境有很大不同。其次,开发者要花力气学习市面上的多种不同 Enclave 硬件架构。再有,目前主流的集群调度系统(如 K8s)还不支持 Enclave,限制了 Enclave 的大规模使用。以基于 Intel SGX CPU 开发机密计算应用程序为例。SGX 应用是一种基于划分的模型:在用户态的(不可信)应用程序(上图红色部分)可以嵌入 SGX TEE 保护的区域(上图绿色部分),被称为 Enclave。支持 SGX 的 Intel CPU 保证 Enclave 中的受保护内容是在内存中加密的,并且与外界强隔离。外界的代码如果想进入 Enclave 中执行其中的可信代码必须通过指定的入口点,后者可以实施访问控制和安全检查以保证 Enclave 无法被外界滥用。由于 SGX 应用程序是基于这种划分的架构,应用开发者通常需要使用某种 SGX SDK,比如 Intel SGX SDK、Open Enclave SDK、Google Asylo 或 Apache Rust SGX SDK等。但无论使用上述哪种 SDK,开发者会遭遇下面的开发困境:必须将目标应用做二分:开发者需要决定哪些组件应该置于 Enclave 内部,哪些置于 Enclave 外部,以及双方如何通信。对于复杂的应用,确定高效、合理且安全的划分方案本身就是一件颇具挑战的工作,更不要说实施划分所需的工程量。被限定在某个编程语言:无论使用上述哪种 SDK 开发,一个开发者都将被限定在该 SDK 所支持的语言,这通常意味着 C/C++(当使用 Intel SGX SDK、Open Enclave SDK 或 Google Asylo 时),而无法使用 Java、Python、Go 等更加友好的编程语言。只能获得很有限的功能:出于硬件限制和安全考虑,Enclave 中是无法直接访问 Enclave 外的(不可信)OS 的。由于 Enclave 中缺乏 OS 的支持,各种 SDK 只能提供普通不可信环境下的一个很小的功能子集,这使得很多现有的软件库或工具都无法在 Enclave 中运行。上述困境使得为 SGX 开发应用成为一件十分痛苦的事,制约了 SGX 和机密计算的普及度和接受度。蚂蚁机密计算软件栈 SOFAEnclave为了解决这些挑战,蚂蚁开发了 SOFAEnclave 机密计算软件栈,分为如图所示的三个部分:Occlum LibOSOcculum 是蚂蚁开源的 TEE 操作系统,也是 CCC 机密计算联盟中第一个由中国公司发起的开源项目。Occlum 提供 POSIX 编程接口,支持多种主流语言(C/C++, Java, Python, Go, Rust 等),支持多种安全文件系统。可以说,Occlum 提供了一个兼容 Linux 的 Enclave 运行环境,使得机密计算可以轻松支持已有的应用,也使得机密应用开发者复用原有开发技能。Occlum 不但在工业界场景中得到了广泛的应用,而且也在系统顶会 ASPLOS 2020 发表了学术论文,代表了机密计算业界的领先水平。从架构上来看,Occlum 不但提供基本的类似 Linux 的操作系统能力,而且提供一个类似 Docker 的用户使用接口,比如这里的 Occlum build 和 Occlum run 等都跟 Docker 的命令类似。社区方面,Occlum 是阿里巴巴 Inclavare Containers 的缺省运行时,也在与 Hyperledger Avalon 等其他社区项目合作。同时,Occlum 已经捐赠给机密计算联盟 CCC,目前是唯一一个来自中国的开源项目。另外,值得一提的是,微软 Azure Cloud 在去年 9 月的 Microsoft Ignite 大会上介绍机密计算技术的新进展时,还公开推荐在 Azure 上基于 Occlum 开发机密计算应用。Occlum开源地址:https://github.com/occlum/occlumHyperEnclave前面提到,市场上目前有多种 Enclave 硬件平台。这些 Enclave 各有特点,但也给开发者带来了较大的学习负担。作为这些硬件的用户,蚂蚁技术团队其实希望有一个统一的 Enclave 抽象,另外,他们也希望能对 Enclave 的启动和证明有更灵活的控制。针对上述问题,蚂蚁机密计算团队提出了机密计算硬件虚拟化技术 HyperEnclave。这是一个统一的 Enclave 平台。作为抽象层,它既能映射到现有的各种 Enclave 硬件实现,也能使用未来的硬件能力,比如 Intel MKTME/TDX。“它甚至可以支持没有 Enclave 扩展的机器,在这种机器上我们基于虚拟化技术实现了隔离机制 —— 我们开发了一个 Type 1.5 的 hypervisor 用来创建和管理基于虚拟化的 Enclave。在可信方面,我们基于可信计算技术(如 TPM 等)实现了由用户灵活掌控的信任机制。”基于 HyperEnclave,加上 AMD SEV 或者 Intel MKTME 等内存加密硬件能力,HyperEnclave 也可以防护物理攻击。有趣的是,HyperEnclave 支持现有的 Enclave SDK。这意味着用户已有的只能跑在 x86 平台的 Encalve 应用,现在可以运行在 HyperEnclave 支持的任何硬件平台上(包括国产 CPU),极大的缓解了用户 Enclave 代码跨平台移植的困扰,同时使得用户对信任链有更灵活的控制。我们详细看一下这个系统的生命周期各阶段。首先 Linux 系统像往常一样启动。接着,我们的 hypervisor 模块开始加载。Hypervisor 加载完毕之后,会把原来的 Linux host 降级为一个不被信任的 guest。这个 Hypevisor 支持创建 Enclave 虚拟机。Enclave 虚拟机支持传统机密计算 SDK 提供的二分式编程模型。Enclave 虚拟机也支持利用 Occlum 将整个应用运行在 Enclave 里面。总结一下这个虚拟化技术的特点:第一,安全第一的设计原则。TCB 是一个非常小的、可形式化验证的、用内存安全语言 RUST 开发的 hypervisor。第二,支持基于 TPM/TXT 的 Enclave 可信启动和远程证明。第三,兼容 Linux 已有生态。前面提到这是一个 Type 1.5 Hypervisor,顾名思义这是一个兼具Type 1 & Type 2 特点的 Hypervisor,更明确地说就是,它 boot like type 2, 但 run like type 1。这样一来我们可以很好地适应目前主流的 Linux 部署方式。另外,这个 Hypervisor 也可以跟 demoted Linux 里面的 KVM 很好地配合。第四,我们可以比较容易地引入硬件提供的内存加密能力,比如 Intel MKTME/TDX 或者 AMD SEV。KubeTEE前面提到的 Occlum 和 HyperEnclave 都还是针对单个计算节点的技术。但是目前的互联网应用都是基于大规模集群的,尤其是基于 Kubernetes 的。Kubernetes 提供了很多基础的集群管理、调度、和监控能力,但这些能力并不能很好适用到机密计算场景。首先我们需要让Kubernetes 能够认识 Enclave 硬件,将 enclave 暴露给容器,监控 Enclave 资源,并处理Enclave 特有的事务比如远程证明等等。闫守孟团队研发的 KubeTEE 就是 Kubernetes 和 Enclave 也就是 TEE 的有机结合。基于KubeTEE,用户可以使用 kubernetes 的工作流程来轻松管理机密计算集群,部署 Enclave 服务,使用 Enclave 中间件等等。KubeTEE 也包含一个叫做 AECS 的组件,基于机密计算的远程证明机制,简化集群内 Enclave 的密钥分发和部署过程。通过以上三个组件,蚂蚁开源的 SOFAEnclaves 技术栈解决了机密计算目前实际应用中的三大难题。相关技术和理念在工业界和学术界都获得了广泛认可,处于世界领先水平。Rust 闪耀机密计算我们注意到,在蚂蚁开源的 SOFAEnclaves 软件栈中,Rust 语言扮演了十分重要的角色。其中 Occlum 和 HypeEnclave 两大组件都主要由 Rust 语言开发完成。闫守孟告诉我们,作为一门兼顾安全性与高性能的新兴编程语言,Rust 语言在蚂蚁集团内部已经被广泛运用,尤其是他所在的机密计算领域,Rust 语言成为了项目开发的主力语言。“我们的东西基本上都是用 Rust 写的。一方面是 Rust 语言生态已经足够成熟,其内存安全等特性是我们非常看重的。此外,Rust 的开发效率也非常高,大大提升了我们团队的生产力。”据悉,在蚂蚁机密计算技术团队中,有一位资深的 Rust 布道师 —— Occlum 的核心开发者田洪亮。关于 Rust 语言在蚂蚁内部的推广,这里面还有一个小故事。当时,在蚂蚁内部,Java 技术栈是主流,而田洪亮则是 Rust 语言的忠实拥趸,虽然公司允许他用 Rust 开展工作,但知音难觅的他有点小郁闷。不过很快,田洪亮参加了支付宝内部的一个编程比赛,100 名参赛同事里面只有他一个人用 Rust,其他人要么用 Java 要么用 Python,从性能上来说,要比 Rust 慢很多。这一下田洪亮相当于开挂,在这场比赛中“大杀特杀”。正是通过这场比赛,Rust 的名声在公司内打响了,有不少同事对 Rust 表示兴趣。于是趁热打铁,田洪亮在公司内分享了关于 Rust 的公开课,还成为阿里云的 Rust 布道师。在 Gartner 发布的 2020 年云安全技术成熟度曲线报告中,机密计算被列为 33 种重要技术之一,并预测在未来 5 - 10 年内成为最普遍的云原生安全技术。闫守孟表示,“我们希望 SOFAEnclave 机密计算软件栈能帮助大家降低机密计算的门槛,促进云原生到可信原生的演进。SOFAEnclave 的三个组件中,Occlum、KubeTEE 已经开源,HyperEnclave 也即将开源。希望跟业界加强交流合作!”受访嘉宾介绍闫守孟是蚂蚁集团资深技术专家和负责机密计算方向的研发总监。他目前的研究兴趣是基于可信执行环境 TEE 的机密计算技术。他领导了蚂蚁集团 SOFAEnclave(Occlum、HyperEnclave、KubeTEE 等)机密计算软件栈的研发,发起并主导了国内外多项TEE标准的制定。加入蚂蚁之前,闫守孟在 Intel 中国研究院任高级主任研究员,主要从事安全隔离技术的研究,研究成果融入 Intel 相关软硬件产品。加入 Intel 之前,他在 2005 年从西北工业大学计算机学院获得博士学位。他拥有 20 余项专利,并在 ASPLOS, PLDI, FSE, MM 等顶级会议发表文章。延伸阅读人人都可以“机密计算”:Occlum 使用入门和技术揭秘 | 开源WebAssembly 在 MOSN 中的实践 - 基础框架篇Protocol Extension Base On Wasm——协议扩展篇MOSN 的无人值守变更实践
文章
Rust  ·  Kubernetes  ·  安全  ·  Cloud Native  ·  Linux  ·  开发工具  ·  数据安全/隐私保护  ·  虚拟化  ·  开发者  ·  容器
2021-07-13
揭秘 AnolisOS 国密生态,想要看懂这一篇就够了
作者:张天佳、杨洋来源:OpenAnolis此文原系 2021 年阿里云开发者大会,开源操作系统社区和生态分论坛,题为《国密技术开发与实践》的分享会后解读。AnolisOS 国密是社区在 AnolisOS 上做的国密技术解决方案,非常欢迎业界有兴趣的开发者能够参与到 OpenAnolis 社区,为国内的基础软件生态添砖加瓦。演讲嘉宾:杨洋:蚂蚁集团高级技术专家,主导开发了 BabaSSL,也是国内唯一的一个 OpenSSL maintainer,参与起草并推动 RFC8998 标准国际化。张天佳:阿里云技术专家,主要负责 AnolisOS 上国密技术的开发和应用,参与实现了 libgcrypt 中的国密算法和 Linux 内核中的 SM2 算法。让我们穿越回现场:缘起说到密码算法,大家一定很熟悉 MD5,AES,RSA 这些通用的国际标准算法,这也是目前我们普遍采用的密码学算法,它们在数据安全、通信、区块链等众多领域都有着广泛的应用。众所周知,这些算法标准都是国外制定的,在某些情况下这会对国内信息安全有不利影响。当下有实力的国家,甚至有些大公司都制定了自己的算法标准。顾名思义,国密就是密码算法的国产化,跟其它领域一样,密码算法的国产化已经势不可挡,这也是我们必须要做的事情。中国的国密算法为我们提供了一个新的选择,在必要的场合中可以选择替代那些国际主流算法,尤其是当下国际贸易冲突,技术封锁不可忽视的大环境下,大规模推广和采用国密算法将为国内重要的网络基础设施提供可靠的数据安全保障。国密简介我是谁,从哪里来?国密是一个口语化的称呼,官方名称是国家商用密码,简称商密,拼音缩写是 SM,这也是国密标准中具体算法名字的来源。国密是用于商用的、不涉及国家秘密的密码技术。国密标准完全由中国密码管理局制定,主要的技术实现也基本是国内开发人员完成的,这对摆脱国外的密码技术和产品依赖是非常有利的。到哪里去?自 2012 年以来,SM2/3/4 的国密标准陆续公布,目前国密技术生态基本处于一个正在逐步走向成熟的阶段,但国内密码基础软件在采用国密算法方面仍处于碎片化的状态,比如我们经常可以看到各种个人或组织名义开源的支持国密算法的库;此外这些开源项目的安全更新和社区活跃也都做的不好。国密的推广仍然需要我们中国基础软件的开发者和用户共同努力。2020 年 1 月 1 日,《中华人民共和国密码法》正式实施,从法律层面规范了国家商用密码的应用和管理,这也为推广和应用国密提供了必要的法律保障。与国际算法的对比这里是国密算法和国际通用算法的一个对比,可以直观地看到国密的一个基本情况:针对各种常用的国际能用算法类型,比如对称算法,公钥算法和消息摘要算法,国密标准都定义了对应的相同功能的国密算法,比如 SM4提供了与 AES 同样的加密强度,并且支持各种加密模式;SM2是基于椭圆曲线的公钥算法,同时定义了非对称加解密,数字签名和密钥交换标准,相对于 RSA,SM2 的密钥更短,但支持的加密强度却更高;SM3是国密定义的消息摘要算法标准,摘要长度是固定的 256 位,强度等同于 SHA256。除了基础的算法,国密标准也定义了 TLCP国密双证书协议 ,用以支持国内的传输层安全协议。这里还有一个好消息,今年三月份,TLS1.3+国密单证书协议正式被国际标准所承认,并且以 RFC8998 标准发布,这意味着我们可以选择在 TLS1.3 协议中使用完整的国密套件,目前我们也在联系正规浏览器厂商支持这个标准的实施和应用。同时国密也定义了使用国密算法的 X509 证书,使用 SM3 哈希算法,SM2 算法作为数字签名,证书类型是 SM2-with-SM3。对开发者来说,国密提供了一个选择,可以选择从国际通用算法平滑迁移过来。除此之外,国密还有其它一些算法标准,是不太常用的,比如 SM9,ZUC 算法等。BabaSSL 前世今生BabaSSL 是主打国密的密码算法库,与 OpenSSL 1.1.1 保持兼容,作为国密的密码算法解决方案而诞生。BabaSSL 是基于之前蚂蚁集团和阿里集团内部的 OpenSSL 版本合并而来,并首次进行了开源。BabaSSL 的含义是:灵巧、轻快且靠谱的密码学和 SSL/TLS 工具库。BabaSSL 的绿色商标,是基于阿里的橙色和蚂蚁的蓝色混合而来,也意味着我们希望将 BabaSSL 打造成一个灵活、小巧并且健壮的基础密码学库。BabaSSL 目前在阿里集团和蚂蚁集团内部得到了非常广泛的使用。从具体场景来看,有如下三个方面,分别是存储、网络和端上的设备。其中网络服务的场景,是 BabaSSL 最大的支撑场景,例如淘宝、天猫、阿里云等各种涉及到链路加密的服务器端。此外移动端 App,例如支付宝手机 App 中集成了 BabaSSL 来实现多种密码学的能力。开源BabaSSL 已经在去年的 10 月份进行了开源,目前代码是托管在 OpenAnolis上 ,当前开源的版本是 8.2.0,也是我们目前最新的稳定版本。目前 BabaSSL 在阿里内部使用的版本和开源版本之间存在一定的差异,我们目前正在逐步把内部版本的功能特性迁移到开源版本上进行开源,最终变成一个统一的开源版本,那么届时阿里内部也完全依赖于这个开源的版本,而不会再保留内部的闭源版本。特色功能以下是 BabaSSL 当前最新稳定版本 8.2.0 的主要特色功能特性:基于 OpenSSL 1.1.1,具备 OpenSSL 1.1.1 的全部能力并且保持兼容;支持国密 SM2, SM3 和 SM4,并对 OpenSSL 1.1.1 中所欠缺的 SM2 能力,比如 X509 证书的签发和验证功能进行了补全;GM/T 0024 和 TLCP 国密双证书 TLS 协议;支持 RFC 8998:TLS 1.3+国密单证书;提供了对 IETF 正在标准化过程中的 Delegated Credentials;支持 IETF QUIC API 底层密码学能力;更加完善的 SM2 算法支持,比如 X.509 证书签发、验签的支持;正在申请软件密码模块一级资质。与 OpenSSL 对比接下来是大家很关心的 BabaSSL 和 OpenSSL 这种老牌的密码算法库之间的区别:从图上可以看到一些主要区别:对于一些新的密码学技术标准,BabaSSL 会采取一种相对激进的策略快速跟进,比如在 IETF 中一些正在标准化流程中的技术方案,例如 delegated credentials, compact TLS 等,会进行原型的实现和快速跟进,而 OpenSSL 则是相对保守,因为 OpenSSL 社区的策略是原则上只实现已经发布了的国际标准和国家标准。在对于国密算法、国密协议、国密的监管合规、云计算厂商的深度集成、以及国产化硬件等方面,BabaSSL 会进行更加深度和广泛的支持,而 OpenSSL 则支持的比较有限。对于 API 的易用程度,由于没有历史包袱,所以 BabaSSL 可以提供更加简单易用的 API,而 OpenSSL 的 API 则相对复杂。 对于资源受限的嵌入式设备,BabaSSL 会进行体积裁剪和内存使用量的规划,OpenSSL 则明确表示没有相关的计划。未来规划这个是一个后续 BabaSSL 未来的版本规划和特性支持,基本上是每半年一个版本,涵盖了多种新的密码领域技术的支持,包括对 IETF 的几个 RFC 草稿的实现、国产化硬件的支持以及未来对于后量子密码学以及同态加密等前沿技术的支持:支持 MPKEncrypted SNICompressed CertificateCompact TLSSM 算法优化支持国产化 CPU 的国密算法指令集体积裁剪,内存使用量优化Tink APIZUC,SM9PQC同态加密算法国密生态架构万事俱备,有了基础国密算法支持,我们便可以构建出一个围绕国密算法展开的基础软件生态。这是一个国密生态的垂直场景,也是我们在 AnolisOS 上的国密生态架构,同时它也是一个全栈国密解决方案:从底层固件,内核,到基础密码学库,在主要链路上做国密改造,最终形成一个完整的基于国密的安全信任链条。图上右边是一些垂直的国密应用场景,比如 SecureBoot,IMA,内核模块签名,文件完整性校验等。到目前为止,我们已经在 Linux 内核,BabaSSL,libgcrypt,gnulib 等主流的基础组件中支持了国密算法,这部分的工作都已经回馈到了上游开源社区,有兴趣的开发者可以直接拿来使用或者作为参考,这些特性功能之后也会率先在 AnolisOS 上输出,达到一个开箱即用的原生支持国密的 OS。从中也能看到,国密生态涉及到的软件栈非常多,形态也是各种各样,要逐步完善这个生态,还有很长的路要走。近几年的国际技术封锁也给了我们做这件事的决心和动力。目前我们已经和统信,海光等厂商有一些合作,也非常欢迎业界有兴趣的开发者能够参与到社区,一起来做这个事情,之后我们的工作都会在 Anolis 社区以开源方式运作,秉着开放包容态度,继续补充完善这个生态,最终达到的一个目标是:整个安全信任链是完全建立在国密算法之上。国密在 IMA 和 modsign 的应用我们知道,密码学算法从来就是为安全服务的,我们来看两个在安全领域具体国密改造的例子。IMA 是 Linux 内核提供一个文件完整性度量架构,用于检测文件是否被恶意篡改,内核模块签名的目的是类似的,用于检测模块的发行源头是否可信。它们都提供了自己的签名工具,签名工具依赖 BabaSSL 提供的 SM2 签名文件的能力,用于在用户态做签名。文件签名的验证是在内核里完成的,由于内核不能直接使用应用层的库,为了支持在 Linux 内核里验证文件签名,我们在内核里实现了国密 SM2/3/4 算法以及国密证书的支持,用来验证签名是否合法。通过对相应软件栈的改造,我们完全基于国密算法构建了 IMA 和内核模块签名的安全机制,而这些之前都是由国际算法来保证的。全栈国密 SIG以下是我们在 OpenAnolis 上的全栈国密 SIG,非常欢迎有兴趣的开发能参与到社区中来,为中国的基础软件安全添砖加瓦。SIG地址:https://openanolis.cn/sig/crypto代码库:https://codeup.openanolis.cn/codeup/crypto欢迎Star:https://babassl.github.io/本周推荐阅读助力数据安全:蚂蚁携手英特尔共同打造验证PPML解决方案Rust 大展拳脚的新兴领域:机密计算开源项目是如何让这个世界更安全的?积跬步至千里:QUIC 协议在蚂蚁集团落地之综述
文章
存储  ·  Rust  ·  算法  ·  安全  ·  Linux  ·  API  ·  网络安全  ·  区块链  ·  数据安全/隐私保护  ·  开发者
2021-07-13
一行“无用”的枚举反使Rust执行效率提升10%,编程到最后都是极致的艺术!
最近不少读者都留言说博客中的代码越来越反哺归真,但讨论的问题反倒越来越高大上了,从并发到乱序执行再到内存布局各种放飞自我。其实这倒不是什么放飞,只是Rust对我来说学习门槛太高了,学习过程中的挫败感也很强,在写完了之前的《Rust胖指针胖到底在哪》之后笔者一度决定脱坑Rust了,但截至本周这个目标还是没有实现,因为我所在的Rust学习群,有一个灵魂拷问,Rust的技术本质什么?不回答好这个问题,我简真是没法得到安宁。Rust枚举的本质到底是什么?1.枚举与一般变量定义的比较:首先说在枚举的处理上Rust与C/C++比较一致,从汇编的角度上看枚举和普通的变量声明的最大区别在于,枚举多存了一个类型的描述符。我们先来看下面的代码:#[derive(Debug)]enum IpAddr {V4(u8, u8, u8, u8),V6(String),}fn main(){ let a=127; let b=0; let c=0; let d=1; let home = IpAddr::V4(127, 0, 0, 1); println!("{:#?}", home); }IP地址是枚举比较适合使用的场景,IP地址就是分为IPV6和IPV4两种细分类型,。与一般的结构体不同,IPV6与IPV4这两种类型是平等的关系,相互独立,非此即彼,而并非是IP类型下的两个元素,因此这时使用枚举类型IpAddr可以比较好的抽象IP地址这种场景。将以上代码进行反汇编,可以看到与普通的变量定义与声明相比枚举对象的定义除了将相应的值存入栈以外,还会多存一个枚举的信息详见下图标红注释:2.枚举与结构体的异同:我们还是以IP为例说明,IP地址分为V4与V6两大类型,不过单从IPV4的角度上看,如IP地址:127.0.0.1,其中每个网段,对于IPV4的址来说都其中的一部分,是共同组成的关系,这就比较适合使用结构的方式来进行定义,具体如下面的代码:#[derive(Debug)]enum IpAddr {V4(u8, u8, u8, u8),V6(String),}#[derive(Debug)]struct IPV4(u8, u8, u8,u8);fn main(){ let a=127; let b=0; let c=0; let d=1; let home = IpAddr::V4(127, 0, 0, 1); let ipv4home= IPV4(a,b,c,d); let remotehost = IpAddr::V4(119, 3, 187, 35); let loopback = IpAddr::V6(String::from("::1")); let loopStr=String::from("::1"); let remotehost1 = IpAddr::V6(String::from("1030::C9B4:FF12:48AA:1A2B")); println!("{:#?}", home); println!("{:#?}", loopback); println!("{}",loopStr); println!("{:?}", remotehost); println!("{:?}", remotehost1); println!("{:?}",ipv4home);}将上述代码反汇编以后,可以看到与结构体相比,枚举也只是增加了一个枚举类型的记录。综上所述我们基本可以把枚举当成一个基本类型的变量声明,只是编译器会通过0和1、2这种序号信息,记录下枚举对象具体是IPV4还是IPV6的信息仅此而已。当然一些细微的调整也会对编程范式造成革命性的进展,不过这些形而上的哲学思考,注Rust反汇编与gdb调试的方案在前文《Rust胖指针到底胖在哪?》有详细介绍,其中反汇编的方法如下:rustc -g rust源文件名.rsobjdump -S 编译后的文件名一行无关代码,却让效率提高10%?以上有关枚举的说明部分,比较容易理解,不过这不是今天的重点。最近我所在的Rust学习群有不少同仁正在做一些并发和内存布局方面的研究,我一顺手恰好将上面的代码实际上放在了一个Rust的并行原型程序中了,结果却意外发现执行时间缩短了5%-10%,我们刚刚也说了枚举类型与一般的变量定义区别不大,因此把代码简化后如下:use std::thread;fn main() { let mut s = String::with_capacity(100000000); let mut s1 = String::with_capacity(100000000); let handle = thread::spawn(move || { let mut i = 0; while i < 10000000 { s.push_str("hello"); i += 1; } }); let handle1 = thread::spawn(move || { let mut i = 0; while i < 10000000 { s1.push_str("hello"); i += 1; } }); handle.join().unwrap(); handle1.join().unwrap();}上述代码的执行时间测试结果如下:[root@ecs-a4d3 hello_world]# rustc hello7.rs[root@ecs-a4d3 hello_world]# time ./hello7real 0m0.999suser 0m1.906ssys 0m0.050s[root@ecs-a4d3 hello_world]# time ./hello7real 0m1.093suser 0m2.005ssys 0m0.060s[root@ecs-a4d3 hello_world]# time ./hello7real 0m1.079suser 0m1.979ssys 0m0.069s[root@ecs-a4d3 hello_world]# time ./hello7real 0m1.011suser 0m1.902ssys 0m0.066s[root@ecs-a4d3 hello_world]# time ./hello7real 0m1.031suser 0m1.944ssys 0m0.053s但是在定义了一个无关的变量,并打印的步骤之后,代码如下:use std::thread;fn main() { let mut s = String::with_capacity(100000000); let reverbit="abcdefghijk"; let mut s1 = String::with_capacity(100000000); let handle = thread::spawn(move || { let mut i = 0; while i < 10000000 { s.push_str("hello"); i += 1; } }); let handle1 = thread::spawn(move || { let mut i = 0; while i < 10000000 { s1.push_str("hello"); i += 1; } }); handle.join().unwrap(); handle1.join().unwrap(); println!("{}",reverbit);}在加了这个无关的变量定义之后,这段代码的执行时间和之前相比至少缩短了5%,这个成绩还是在多执行了print这个IO操作的基础上达到的,[root@ecs-a4d3 hello_world]# time ./hello7abcdefghijkreal 0m0.963suser 0m1.856ssys 0m0.050s[root@ecs-a4d3 hello_world]# time ./hello7abcdefghijkreal 0m0.960suser 0m1.844ssys 0m0.055s[root@ecs-a4d3 hello_world]# time ./hello7abcdefghijkreal 0m0.964suser 0m1.846ssys 0m0.065s[root@ecs-a4d3 hello_world]# time ./hello7abcdefghijkreal 0m0.958suser 0m1.858ssys 0m0.045s[root@ecs-a4d3 hello_world]# time ./hello7abcdefghijkreal 0m0.963suser 0m1.862ssys 0m0.052s[root@ecs-a4d3 hello_world]# time ./hello7abcdefghijkreal 0m0.963suser 0m1.853ssys 0m0.047s在确认编译方法没有问题,之后我基本确认这个性能提升不是一个可以忽略的偶然事件。前导小贴士初始化内存时尽量指定合适的容量:这段Rust程序其实就是通过两个线程handle、handle1分别去处理加工s、s1两个字符串,从程序本身来讲,只有一个小Tip要讲,就是初始化字符串的方式是通过 String::with_capacity方法来进行的,这里先回顾一下上次博客中所说的String内存布局。在上面这个内存状态下,执行push_str("!")操作,字符串的capacity容量还没有溢出,不会向系统重新申请堆内存空间,也不会造成ptr指针的变化,只是将len+1,并在o后再加上!,完成后如下图: 也就是说提前将capacity容量设置成比较合适的大小,将避免反复向系统申请动态堆内存,提升程序运行效率。无关代码提高效率的原因何在?这里先给出的结论,这又是一个内存、缓存以及CPU多核之间的竞争协同效率问题。在分析这个问题之前我们还是要先回到上次博文中内容,其中String对象在栈上的三个成员ptr、capacity和len都是64位长,加在一起共192位也就是24byte,详见下图:X86CPU的高速缓存每行容量却是64byte,也就是说按照我们最初的定义方式: let mut s = String::with_capacity(100000000); let mut s1 = String::with_capacity(100000000);字符串s和s1占用连续的48byte栈空间,这种内存分配布局就使它们很可能位于同一个内存缓存行上,也就是说不同的CPU在分别操作s和s1时,其实操作的是同一缓存行,那么这样的操作就可能相互影响,从而使效率降低。我们知道现代的CPU都配备了高速缓存,按照多核高速缓存同步的MESI协议约定,每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid),其中:M:代表该缓存行中的内容被修改,并且该缓存行只被缓存在该CPU中。这个状态代表缓存行的数据和内存中的数据不同。E:代表该缓存行对应内存中的内容只被该CPU缓存,其他CPU没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的数据与内存的数据一致。I:代表该缓存行中的内容无效。S:该状态意味着数据不止存在本地CPU缓存中,还存在其它CPU的缓存中。这个状态的数据和内存中的数据也是一致的。不过只要有CPU修改该缓存行都会使该行状态变成 I 。四种状态的状态转移图如下:我们上文也提到了,在容量足够的情况下,执行执行push_str操作,并不会使程序向系统再次malloc内存,但是会使len的值有所变化,那么由于不同CPU在同时处理s1和s时其实是在操作同一缓存行,CPU0在操作s的len的同时CPU1很可能也在操作s1的len,这种remote write的操作,使该缓存行的状态总是会在S和I之间进行状态迁移,而一旦状态变为I将耗费比较多的时间进行状态同步。因此我们可以基本得出let reverbit="abcdefghijk";这行无关的代码之后,改变了栈上的内存空间布局,无意中使s1和s被划分到了不同的缓存行上,这也使最终的执行效率有所提高。当然由于dump高速缓存的状态将从很大程度上改变程序的行为,因此本文的求证过程不像前几篇那么严谨,如有错漏还请各位读者指正。这行看似啥用没有的let reverbit="abcdefghijk";代码最终却使效率提升了近10%,这也让人不得不感叹编程到了最后绝对是一门艺术,闲棋与闲子反而最显功力。
文章
缓存  ·  Rust  ·  网络协议  ·  NoSQL  ·  编译器  ·  C++
2021-07-11
1
...
11 12 13 14 15 16 17 18 19 20
跳转至:
金融级分布式架构
81 人关注 | 1 讨论 | 246 内容
+ 订阅
  • 深入 HTTP/3(2)|不那么 Boring 的 SSL
  • 蚂蚁集团 Service Mesh 进展回顾与展望
  • SOFA Serverless 体系助力业务极速研发
查看更多 >
龙蜥操作系统
36 人关注 | 3 讨论 | 112 内容
+ 订阅
  • 虚拟化解决方案 virtio 的技术趋势与 DPU 实践解读 | 龙蜥技术
  • 携手中科海光,龙蜥社区正式上线首个 CSV 机密容器解决方案
  • 龙蜥社区第八次运营委员会会议顺利召开
查看更多 >
AlibabaF2E
1356 人关注 | 24 讨论 | 374 内容
+ 订阅
  • 第十六届 D2 前端技术论坛完成 6 大专场 21 个话题集结,快来划重点,你一定会有所收获!
  • 第十六届 D2 的第一波话题新鲜出炉啦~干货满满,不容错过!
  • 在微前端中加载 Vite 应用
查看更多 >
开发与运维
5197 人关注 | 125229 讨论 | 188791 内容
+ 订阅
  • Java——三个修饰符
  • 微信小程序实时定位的要做的那些事,你学废了吗?(附示例)
  • HTML基础—插曲
查看更多 >
安全
1035 人关注 | 23268 讨论 | 53124 内容
+ 订阅
  • HTML基础—插曲
  • UPC——校门内的树—>二分
  • 【问题归纳】前端开发问题集 | css 设置div宽高比1:2 上
查看更多 >