暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

【闲谈 rust 语言】内存安全与垃圾回收器

最后的叶子的奇妙小屋 2021-09-24
2857

这篇文章通俗地谈谈内存安全的概念、手动内存管理的问题、垃圾回收器的优劣,再讲讲 rust 解决内存安全问题时独特的做法。

手动内存管理

现在流行的操作系统和数据库等基础软件,多是使用 C 或 C++ 编写的。在 C 和 C++ 中,每当使用动态内存分配(如使用变长数组、变长字符串)时,需要手动分配和释放内存。以 C 语言为例:

    // 为变量 a 分配内存区域
    char * a = malloc(64);
    // 使用变量 a
    // ...
    // 释放 a 的内存区域
    free(a);

    每次使用动态内存分配的变量之前,总是要在一开始用 malloc 分配内存区域,在末尾用 free 释放掉它。这样很麻烦,而且经常会遗漏 free ,所以后来“垃圾回收器”被发明出来了。

    垃圾回收器

    如果使用包含有垃圾回收器的编程语言,就可以省掉 free 的烦恼。以 JavaScript 为例:

      // 创建一个新的数组
      var arr = new Array(64);
      // 使用变量 arr
      // ...

      在一开始时,通过 new 分配的内存区域会由垃圾回收器来统一管理,并不需要在代码的末尾释放。每隔一段时间,垃圾回收器会做一个检测,自动识别出来到底哪些内存区域可以被释放了。这让写代码方便了很多。

      当然,垃圾回收器的问题也是显而易见的。

      一是垃圾回收器需要间歇性地检测识别可释放的内存区域,这会产生不受代码控制的“垃圾回收中断”,像是代码暂停执行了一样。

      二是垃圾回收器并不是即时释放内存区域、很多内存区域并不会在不再使用时立即释放,导致总体占用的内存偏大。

      三是垃圾回收器需要完全管理编程语言中所有分配出来的内存区域,这导致同时使用多门带有垃圾回收器的编程语言时,总是需要更多内存拷贝操作,编程语言之间交互的代码更加繁琐。

      因而,对性能要求高的基础软件、常被其他编程语言引用的底层库,一般不使用带有垃圾回收器的编程语言来实现。

      尽管如此,现在很多编程语言仍然带有垃圾回收器。因为除了代码编写方便之外,垃圾回收器还带来了一个重要的好处,称为“内存安全”。

      内存安全问题

      垃圾回收器总是在一片内存区域不被使用的时候才去释放它。而如果手工编写 free ,有时很难做到正确使用 free

      最常见的错误用法是 use-after-free :free 调用得过早,导致内存错乱。例如:

        void use_after_free_example_1() {
        // 为变量 a 分配内存区域
        char * a = malloc(64);
        // 释放 a 的内存区域
        free(a);
        // 为变量 b 分配内存区域
        char * b = malloc(64);
        // 向 b 写入一段数据
        strcpy(b, "data of b");
        printf("%s\n", b); // 输出 data of b
        // 此时 a 仍可以使用,但访问到了 b 的数据!
        printf("%s\n", a); // 输出 data of b
        // ...
        }

        在上面这个例子中,由于变量 a 释放得过早,使得后续分配变量 b 时, b 重新使用了 a 刚刚释放掉的这片内存区域,最终导致 ab 相等。

        在实践中,往往情况更加复杂。例如下面这种变形:

          void use_after_free_example_2() {
          // 为变量 a 分配内存区域
          char * a = malloc(64);
          // 让变量 c 和变量 a 指向同一片区域
          char * c = a;
          // 释放 a 的内存区域
          free(a);
          // 为变量 b 分配内存区域
          char * b = malloc(64);
          // 向 b 写入一段数据
          strcpy(b, "data of b");
          printf("%s\n", b); // 输出 data of b
          // 此时 c 仍可以使用,但访问到了 b 的数据!
          printf("%s\n", c); // 输出 data of b
          // ...
          }

          在上面这个例子中,有变量 c 复用了变量 a 的内存区域,而 a 释放时,对变量 c 的使用就会导致内存错乱。

          在实践中,这些复杂的变形往往很难仅凭少数几个开发者就看出问题,也不一定能通过测试就表现出来;即使测试表现出来了问题, debug 往往也很麻烦。何况,还有更多其他类似的内存安全问题,比如多次 free 同一块内存区域、使用未初始化的内存区域、使用数组时下标越界等等。

          一经发布,这些问题会给不怀好意的攻击者留下攻击面。历史上最有名的案例之一是 OpenSSL 的 heartbleed 漏洞。这个漏洞说来也并不复杂:就是将某个已经内存错乱了的变量内容通过网络发送了出去。如果错乱了的内容中刚好包含了需要保密的内容(如证书密钥),就使得攻击者拿到这些保密内容了。当时,众多网站被迫更新了证书;据报道,一些来不及应对的网站受到了攻击。

          另据统计, Google Chrome 中 70% 的安全问题都属于内存安全问题,其中的一半是最容易犯下的 use-after-free 错误。所以,不要以为编码足够小心就能避免这种问题了。

          rust 的解决方式

          出于性能考虑, rust 是没有垃圾回收器的语言,但 rust 有一套完整的体系来保证内存安全。

          首先, rust 没有显式的 free 调用,而是在花括号块的末尾自动释放。例如:

            fn example_1() {
            {
            // 为变量 a 分配内存区域
                    let a: Vec<u8> = Vec::new();
            // 花括号末尾自动释放 a 的内存区域
            }
                let b: Vec<u8> = Vec::new();
            // b 虽然重新利用了 a 刚释放的内存区域
            // 但 a 仅在上面花括号内有效,这里不能再使用
                // 这样就避免了 use-after-free 问题
            }

            如果作为花括号的计算结果抛到花括号外,则释放时机自动延迟到外层花括号末尾。例如:

              fn example_1() {
              let a = {
              // 为变量 a 分配内存区域
                      let a: Vec<u8> = Vec::new();
              a
              // 这里不会释放 a
              };
                  let b: Vec<u8> = Vec::new();
              // a 还未释放
                  // b 和 a 的内存区域不同


              // a 和 b 在这个花括号末尾释放
              }

              rust 的所有权规则规定,一个内存区域只能有一个所有者。例如:

                fn example_2() {
                // 为变量 a 分配内存区域
                    let a: Vec<u8> = Vec::new();
                // 将 Vec 所有权从 a 转移到 c
                let c = a;
                // 此时 a 不能再使用,也不会在末尾被释放


                    let b: Vec<u8> = Vec::new();
                // c 还未释放
                    // b 和 c 的内存区域不同


                // b 和 c 在这个花括号末尾释放
                }

                rust 的借用规则规定,如果变量 c 持有变量 a 的引用,则 c 不能在 a 所在的花括号末尾被抛出。例如:

                  fn example_2() {
                  let c = {
                  // 为变量 a 分配内存区域
                          let a: Vec<u8> = Vec::new();
                          // c 是 a 的引用
                  let c = &a;
                  c
                  // 编译失败!
                  };
                      let b: Vec<u8> = Vec::new();
                  }

                  不符合所有权和借用规则的写法都将直接导致编译失败。(详细的规则比较复杂,这里不再列举了。)

                  rust 的一整套所有权和借用机制可以完整保证内存安全,而且没有额外的运行时开销。这种无垃圾回收器的内存安全机制就是 rust 最重要的设计之一,也是 rust 在编程语言领域理论价值的体现。

                  文章转载自最后的叶子的奇妙小屋,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论