斯坦福-CS110L-Rust-安全编程笔记-全-

斯坦福 CS110L Rust 安全编程笔记(全)

001:系统编程中的安全性

概述

在本节课中,我们将探讨系统编程中安全性的重要性,理解传统语言(如C/C++)和现代语言(如Java/Python)在编写系统软件时各自面临的挑战,并初步了解Rust语言的设计动机。

课程开始前的准备

在正式开始前,请确保您已开启摄像头(如果条件允许),这有助于营造更具互动性的社区氛围。请在不发言时保持静音,以减少背景噪音。如果您有问题,请使用Zoom的“举手”功能示意。我们将尽力在讲座中频繁停顿,以确保解答所有疑问。

请放松并享受课程。如果需要短暂离开或进食,这完全可以接受。我们希望每个人都能有愉快的体验。

讲师介绍

我是Ryan,与Arman一样,也是系统与安全方向的研究生。我热衷于种植,在隔离开始前,这是我的鳄梨农场。我也非常喜欢音乐和陶艺。需要说明的是,我本人也是Rust的新手学习者,将与大家一同进步。我过去教授过CS110课程,并拥有丰富的系统编程背景,我将把这些经验带入本课程。

Arman是我们的Rust专家。本课程的讲座将由我们两人共同完成。特别感谢Will Crichton,他在课程规划中给予了我们大量建议和材料支持。

关于选课同学

目前约有33名同学注册了本课程,还有一些旁听生。我们很高兴能有这样一个充满活力的社区。大家来自世界各地,这可能是本在线季度最酷的事情之一。

根据调查反馈,大家选课的原因主要集中在几个方面:对Rust语言本身感兴趣;希望本课程能支持并丰富CS110的学习体验;关注代码安全性与维护;以及对课程项目感兴趣。

绝大多数同学此前没有或仅有很少的Rust经验,这完全符合我们的预期。如果有同学上过CS242(编程语言)课程,请注意,本课程前半部分的内容对您来说可能是复习,但后半部分关于线程和网络的内容,结合CS110的知识,仍会很有价值。

我们拥有一个背景多元的出色社区。如果您尚未加入Slack,请告知我们,并欢迎在社交频道中介绍自己。

为什么需要这门课程?为什么选择Rust?

为了正确回答这个问题,我们需要从两个角度来审视:一方面,大量系统代码由C/C++编写,我们需问为何不继续使用这些已有三四十年历史的语言?另一方面,有许多更新的语言,如Java、Python、Go,为何不尝试用它们来编写系统代码?这些语言似乎没有内存泄漏等问题,使用起来更简单。

为何不使用C/C++?

让我们首先探讨为何不应继续使用C/C++。我们将在周四详细阐述,这里先看一个方面。

以下是从TutorialsPoint(一个常见的C语言教程网站)复制粘贴的代码。这段程序存在一个重大缺陷。请花一分钟查看,找出问题所在。

#include <stdio.h>
int main() {
    char buffer[10];
    printf("Enter your name: ");
    gets(buffer);
    printf("Hello, %s!\n", buffer);
    return 0;
}

许多同学在聊天中指出是 gets 函数的问题。在解释原因前,我们需要回顾一些CS107中的概念,以理解为何这是一个严重问题。

您不需要懂汇编,但我会展示一些汇编代码来解释C程序运行时的底层情况。在C语言中调用函数时,编译器会将其转换为汇编指令。调用函数时,首先将参数压入栈中。接着执行 call 指令来调用目标函数,该指令会将当前地址(返回地址)压入栈,以便函数返回时知道跳回哪里。然后,call 指令跳转到函数定义处。

函数开始执行时,会保存基指针(base pointer),并为局部变量在栈上分配内存。函数执行完毕后,会弹出局部变量,恢复基指针,然后执行 return 指令。return 指令会从栈中弹出返回地址,并跳转到该地址,从而使程序回到调用函数中。

那么,问题出在哪里?假设函数中有一个用于读取字符串的字符缓冲区。如果读取的字符串内容超过了缓冲区的大小,会发生什么?C语言默认不会在运行时进行边界检查。如果写入的数据超出了缓冲区范围,程序会继续向上(向高地址)写入栈内存。如果写入没有超出栈的范围,就不会触发段错误,程序会继续运行。

这可能导致覆盖栈上的返回地址。这是一个大问题,因为程序将不知道返回到何处。更糟糕的是,如果恶意攻击者能够控制输入到缓冲区的字符串内容,他们可以精心构造输入,使得覆盖后的返回地址指向缓冲区开头。而他们可以在缓冲区开头预先放置恶意的汇编代码。这样,当函数返回时,程序不会返回到原调用处,而是开始执行攻击者放置在缓冲区中的恶意代码。

这就是所谓的“缓冲区溢出”漏洞。著名的“莫里斯蠕虫”病毒(1988年)就利用了此类漏洞。它感染了当时约10%的互联网主机。该蠕虫利用了 fingerd 服务中的一个漏洞。查看 fingerd 的代码,可以看到它使用 gets 函数将输入读入一个512字节的缓冲区,但 gets 函数并不知道缓冲区的实际大小。如果攻击者发送超过512字节的字符串,gets 会愉快地将其复制到缓冲区,导致溢出,并可能覆盖返回地址,从而执行蠕虫代码。

令人惊讶的是,在2020年,互联网上仍然有教程建议使用 gets 函数。你可能会想,专业的工程师不会犯这种低级错误吧?

然而,现实是,即使是最顶尖的公司,在安全方面投入巨大,也仍然难以完全消除此类漏洞。例如,2011年的一项研究中,研究人员通过攻击汽车的远程信息处理模块,利用缓冲区溢出漏洞,最终可以控制汽车的转向、引擎等系统。他们甚至可以通过播放音频文件来发起攻击。

在漏洞数据库中搜索“缓冲区溢出”,结果不计其数,仅2019年就有大量案例。例如,Chrome OS中的一个漏洞仅仅是因为一个“差一错误”(off-by-one error)。研究人员经过努力,最终利用这个微小溢出成功入侵了Chromebook操作系统。

有些漏洞则更为隐蔽。请看这段代码,它也存在缓冲区溢出风险。初看之下,这段代码似乎不错:它进行了边界检查,确保要复制的字节数不超过缓冲区大小。它使用 strncpy 进行复制(这比 strcpy 安全)。然而,这里有一个非常微妙的问题:bytes_to_copy 是一个有符号整型(int),而 strncpy 期望一个无符号的 size_t 类型。如果攻击者提供一个负数的长度值(如-1),当将其转换为无符号的 size_t 时,会发生下溢,变成一个非常大的正数(如40亿),从而导致实际复制的数据远远超出预期。

在C语言中,默认编译这段代码不会产生任何警告或错误。即使是有经验的开发者,也可能忽略这个陷阱。这说明了在C/C++中保证安全的极端困难性。

你可能会问,我们不是有工具(如Valgrind)来检测这类问题吗?Valgrind确实可以检测运行时发生的非法内存访问。但Valgrind进行的是“动态分析”,即它观察程序实际执行时发生的情况。如果恶意输入没有触发溢出,Valgrind就无法发现问题。另一种方法是“静态分析”,即通过分析代码来预测可能发生的问题。但静态分析通常会产生大量误报,因为代码逻辑可能很复杂,某些溢出路径在实际中不可能被执行。因此,尽管工具在不断进步,缓冲区溢出等内存安全问题依然普遍存在。

为何不使用垃圾回收语言?

既然C/C++问题这么多,我们为什么不使用垃圾回收语言(如Java、Python、Go)来编写系统代码呢?垃圾回收意味着你不需要手动分配和释放内存,语言运行时会自动管理内存,这听起来很棒。

用一个比喻来说明:假设宿舍管理员发邮件提议,由他每周为每个房间清理垃圾,费用从宿舍基金中支出,每位学生每天不到50美分。这听起来像是个不错的服务(垃圾回收),但它有几个问题:成本高昂(垃圾回收有显著的开销);具有侵扰性(垃圾回收运行时需要暂停所有工作,即“Stop-The-World”停顿);时间不确定(你无法预知垃圾回收何时发生);此外,在某些对性能有极致要求的场景(如游戏、自动驾驶),不可预测的停顿是无法接受的。

例如,Discord(最初使用Go语言编写)的博客显示,每两分钟就会出现明显的延迟峰值(从10毫秒飙升至300毫秒),这些峰值对应着垃圾回收的停顿。LinkedIn也曾报告在生产环境中遇到超过5秒的“Stop-The-World”停顿。对于实时性要求高的应用,这是致命的。

此外,即使有了垃圾回收,也只能解决内存释放的问题,无法防止其他类型的内存错误(如数据竞争、迭代器失效等),而Rust的设计目标更侧重于全面的安全性,而不仅仅是便利性。

课程结构与要求

本课程应与CS110同步学习,或已修完CS110。课程内容将紧密依托CS110的知识。如果你还没有学习CS110,可能会感到困惑。

课程前半部分将重点结合CS107的内容讨论内存安全,后半部分则将探讨如何在CS110所涉及的线程、网络等上下文中保证安全。

课程组成部分包括:讲座、练习、项目和课堂参与。我们鼓励大家参加讲座,但也会提供录像。练习旨在每周巩固所学知识,为项目做准备,预计每周花费1-3小时。项目有两个,我们将根据功能完成度进行评分。Rust编译器本身具有出色的错误信息和代码风格提示,这能帮助大家成为更好的程序员。

如果你有自己特别感兴趣的项目想法,欢迎告诉我们,我们很乐意支持你进行个性化探索。

总结

本节课我们一起探讨了系统编程中安全性的核心挑战。我们看到了C/C++语言中缓冲区溢出等内存安全问题的普遍性与危险性,即使是有经验的开发者和使用先进工具也难以完全避免。同时,我们也了解了垃圾回收语言虽然提供了内存管理的便利,但其带来的性能开销、不可预测的停顿以及对底层控制力的削弱,使其并非系统编程的完美解决方案。这些挑战正是Rust语言设计的出发点。在接下来的课程中,我们将开始学习Rust如何通过其独特的所有权、借用检查器等机制,试图在提供高级别安全保证的同时,又不牺牲系统编程所需的性能与控制力。

课前准备

在周四上课前,请花10分钟查看以下链接中的代码,尝试找出其中尽可能多的错误(共有7个)。这些错误涵盖了多种概念性问题,能够很好地体现Rust的设计动机。
(链接:https://example.com/buggy_c_code注:原文本未提供有效链接,此处为示意

感谢大家的参与,我们周四再见!

002:内存安全 🛡️

在本节课中,我们将学习Rust如何通过其独特的所有权和借用系统,在编译时防止常见的内存安全问题,例如悬垂指针、双重释放和数据竞争。


概述

上一讲中,Ryan介绍了C/C++中存在的各种内存安全问题,这些问题可能导致安全漏洞和程序错误。本节我们将探讨Rust如何尝试解决这些问题。需要明确的是,Rust并不能完全杜绝程序错误,但它通过编译时的严格检查,使得犯某些特定类型的错误变得更加困难。

为什么内存安全如此棘手?

首先,让我们思考为什么在C/C++中容易出错。这正是在刚才讨论中大家探讨的核心。

以下是几种常见的内存错误类型:

悬垂指针

int* foo() {
    int arr[3] = {1, 2, 3};
    return &arr[0]; // 返回局部数组的地址
}

问题:函数foo返回后,其栈帧被释放,arr的内存区域可能被后续的函数调用覆盖。返回的指针指向无效内存,解引用它会导致未定义行为。

双重释放

int *p = malloc(sizeof(int));
free(p);
free(p); // 对同一内存区域再次调用free

问题:这会破坏堆管理器的内部状态,可能导致程序崩溃,甚至被利用为安全漏洞(例如,在CS155课程中会学习如何利用双重释放进行控制流劫持)。

迭代器失效

std::vector<int> vec = {1, 2, 3};
int* n = &vec[0];
vec.push_back(4); // 可能导致重新分配内存
printf("%d", *n); // n可能指向已释放的内存

问题:在持有指向容器元素的指针或引用时,修改容器(如添加元素)可能导致内存重新分配,使原有的指针失效。这是一种“信任”被破坏的情况:我们以为n指向有效数据,但其底层数据在我们不知情的情况下被改变了。

内存泄漏

int* new_array = malloc(new_size * sizeof(int));
// ... 将旧数组数据拷贝到new_array ...
// 忘记 free(old_array);

问题:分配了新内存后,忘记释放旧内存,导致程序占用的内存不断增长。

这些问题的根源在于,要静态地(不运行程序)证明一个程序没有此类错误是非常困难的,甚至从理论上说,对于通用程序是不可能的(例如停机问题)。Rust的解决方案不是去“证明”程序正确,而是通过限制程序员能编写的程序类型,来保证符合规则的程序一定是安全的。

Rust的核心武器:所有权与借用

面对“证明程序性质很难”这一挑战,Rust选择在语言层面添加约束。这些约束体现在“所有权”和“借用”系统上,它们使得编译器能够在编译时分析并阻止上述错误。

所有权规则 🏷️

所有权是Rust最核心的概念,它遵循三条基本规则:

  1. Rust中的每一个值都有一个被称为其所有者的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域时,这个值将被丢弃(drop)。

这类似于资源获取即初始化(RAII)模式。当拥有资源的对象销毁时,资源自动被清理(如内存被释放、文件被关闭)。

让我们通过代码示例来理解所有权的转移(移动):

fn main() {
    let s = String::from("hello"); // s 是字符串的所有者
    let u = s; // 所有权从 s 移动(move)到 u
    // println!("{}", s); // 错误!s 不再拥有数据,无法使用
    println!("{}", u); // 正确,u 现在是所有者
}

s被赋值给u时,String数据的所有权发生了转移。此后s变为无效,Rust编译器会阻止你使用它,从而避免了悬垂指针。

函数调用也会转移所有权

fn take_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // some_string 离开作用域,`drop`被调用,内存被释放

fn main() {
    let s = String::from("hello");
    take_ownership(s); // s 的所有权被移动到函数里
    // println!("{}", s); // 错误!s 的所有权已经丢失
}

关于Copy类型
对于像整数这样大小固定、存储在栈上的简单类型,赋值操作是复制而非移动。因为它们实现了Copy trait。

fn main() {
    let n = 5; // i32 类型,实现了 Copy
    let m = n; // 值被复制,n 和 m 都是有效的
    println!("n = {}, m = {}", n, m); // 完全正确
}

借用与引用 🔗

如果每个函数调用都要转移所有权,然后再还回来,代码会非常繁琐。为此,Rust提供了借用机制:允许你引用某个值而不获取其所有权。

引用分为两种:

  • 不可变引用 (&T): 允许多个同时存在,但不能修改数据。
  • 可变引用 (&mut T): 只允许同时存在一个,并且可以修改数据。当存在可变引用时,不能同时存在任何不可变引用。

这个规则可以类比为一份合同:

  • 多个读者:许多人可以同时阅读合同(多个不可变引用),这是安全的。
  • 单个作者:当一个人正在修改合同时(一个可变引用),其他人既不能阅读也不能修改,否则读者可能读到不一致的内容。

Rust在编译时强制实施这些规则,从而防止了数据竞争。

以下是借用的例子:

fn main() {
    let mut s = String::from("hello");

    // 可变借用
    change(&mut s);
    println!("{}", s); // 输出 "goodbye"

    // 不可变借用
    let r1 = &s;
    let r2 = &s;
    println!("{} and {}", r1, r2); // 多个不可变借用是允许的

    // 再次可变借用
    make_plural(&mut s);
    println!("{}", s); // 输出 "goodbyes"
}

fn change(some_string: &mut String) {
    *some_string = String::from("goodbye");
}

fn make_plural(some_string: &mut String) {
    some_string.push('s');
}

生命周期 ⏳

生命周期是Rust用来确保引用始终有效的概念。简单来说,引用的生命周期不能长于其引用的数据的生命周期。编译器通过静态分析来检查这一点。

这直接解决了悬垂指针问题:编译器会阻止你返回一个指向局部变量的引用,因为局部变量的生命周期短于返回的引用。

fn dangling() -> &String { // 错误:缺少生命周期标识符
    let s = String::from("hello");
    &s // s 在这里离开作用域并被丢弃。返回的 &s 将成为悬垂引用。
} // Rust 编译器会拒绝编译此代码

当值离开其生命周期时,Rust会自动插入drop调用进行清理。

编译时保障的优势 🚀

Rust的所有权、借用和生命周期规则都是在编译时由编译器检查的。这是一个巨大的优势:

  • 一次编译,多次安全运行:将安全检查从运行时提前到编译时,避免了运行时开销和潜在崩溃。
  • 清晰的错误信息:Rust编译器以提供详细、有帮助的错误信息而闻名,通常会给出修复建议。
  • 兼顾安全与性能:通过编译时的严格检查,Rust能够在提供内存安全保证的同时,不牺牲运行时性能,有时甚至能通过优化超越C/C++的性能。

总结

本节课我们一起学习了Rust保障内存安全的核心机制:

  1. 所有权系统:通过单一所有者和作用域结束自动释放,管理内存生命周期,防止内存泄漏和悬垂指针。
  2. 借用规则:通过严格的不可变/可变引用规则,在编译时防止数据竞争和迭代器失效。
  3. 生命周期:确保引用不会比其引用的数据存活更久,进一步保障内存安全。
  4. 编译时检查:所有这些规则都在编译阶段强制执行,将大量潜在错误扼杀在程序运行之前,实现了安全与性能的平衡。

理解这些概念是掌握Rust的关键。在第一次作业中,你们将会亲身实践并深刻体会到这些规则如何影响代码的编写。请多在Slack上提问和交流。

003:错误处理 🛡️

在本节课中,我们将学习Rust中的错误处理机制,特别是如何通过OptionResult类型安全地处理可能失败的操作,避免C语言中常见的空指针解引用等问题。

概述

首先,恭喜大家完成了第一周的学习。第一周的作业可能对一些人来说有些棘手,需要适应Zoom和各种家庭事务。我们查看了调查结果,发现可能高估了作业的难度,大约有不到一半的人花费了超过三小时。我们将在第二周的练习中尝试缩减范围,使其更易于管理。希望第一周的内容对你有帮助,让你对Rust感到更熟悉。当然,这只是第一周,如果事情仍然感觉有些混乱,请不要担心。事实上,我绘制了反思的词云,最大的词是“令人沮丧的Rust作业”,我觉得这很有趣。

本课程的目标是向你展示C/C++中的问题,而Rust正是对这些问题的回应。Rust是第一个真正获得关注、成为可行的C/C++替代品的语言。通过本课程,你将亲身体验Rust如何应对这些问题,以及这种应对方式本身存在哪些挑战。我们并不期望你在这门课上成为Rust专家。

今天的路线图是:首先回顾Ar在周四介绍的一些所有权概念,然后展示一些具体的代码示例,让你看到所有权是如何工作的(或者更常见的是如何不工作的),最后介绍Rust中的错误处理。Arman将在周四通过一个实际代码演示来展示所有这些内容是如何结合在一起的。

所有权在C语言中的体现

在深入Rust之前,我们先暂停一下,看看如何理解我们已经讨论过的一些概念。所有权概念在C语言中同样存在,只是表现形式不同。

以下是一个来自OpenVSitch项目的C代码注释:

// 获取状态...返回零...并将错误字符串存储在*ap中,返回正数ano值。
// 调用者负责使用free释放*ap。

关键点在于“调用者负责释放*ap”。这个函数为错误字符串分配了一些内存,并返回一个指针,它表示调用者现在负责释放该指针。实际上,这里发生的是:这个函数将某些内存的所有权转移给了调用者。我们在注释中指定了所有权,因为C语言本身没有所有权的编译器概念,但这本质上就是所有权。

另一个例子是借用:

// 任何现有的字典都会被丢弃,并替换为此常量AVDictionary*。
// 调用者仍然拥有此void*指针,并负责释放它。

这类似于Rust中的不可变借用。我们在这里借用了一个引用,临时使用这个指针,但我们没有取得它的所有权,只是借用它。调用者仍然保留所有权,并负责释放它。谁拥有所有权,谁就负责释放这块内存。

有时情况会更复杂。例如,一个注释可能说明:“注意,boot CFS库将在所有对目标K对象的引用都被释放后,释放为调用传入的数据。”这意味着该函数取得了所有权,但会立即将其传递给boot CFS库,该库将负责在适当的时候释放数据。这类似于Linux内核中处理打开文件表或V节点表条目时的引用计数机制。

在C语言中,所有权管理变得非常复杂,因为有时库会实现自己的free函数。它们可能返回一个指向内存的指针,但你不能使用标准的free函数来释放它,必须使用它们自己的释放函数。这可能是因为需要额外的清理工作。有时库甚至会实现自己的内存分配器。

幸运的是,在Rust中,这变得更容易,因为我们有明确的所有权概念:调用者保留所有权,当所有者超出作用域且不再需要时,Rust会自动处理内存释放。Rust会找出哪些析构函数(drop函数)应该与哪些数据关联,并为我们处理这些。

所有权问题在涉及结构体时尤其具有挑战性,无论是C还是Rust。当一个结构体拥有或指向其他内存块时,所有权管理就变得非常复杂。在C中,你可能认为你正在释放一个数据结构,但实际上只释放了它的一部分,你需要调用另一个函数来清理数据结构的其他部分。Rust不允许你犯这些错误,但这也令人沮丧,因为它迫使你提前解决所有所有权问题。

所有权的挑战与Rust的应对

所有权非常复杂,这几乎出现在所有的调查反馈中。每个人都表示所有权令人困惑,难以理解。但这并不是因为Rust中的所有权具有挑战性,而是因为所有权作为一个概念本身就具有挑战性。在C语言中,所有权管理非常困难,这就是为什么我们经常出错并产生许多与内存相关的漏洞。

Rust编译器迫使你在编译时解决所有问题,这样未来就不会有任何出错的可能性。这就像带着一些包袱进入一段关系,而Rust编译器就像那个让你现在就必须解决所有问题的朋友,虽然短期内让你生活困难,但从长远来看对你有益。

关于性能影响,需要区分编译时和运行时发生的情况。在编译时,Rust与C非常不同,因为它有这个所有权模型,并迫使你解决所有权问题。但在运行时,它实际上与C几乎相同。如果你传递所有权,实际上只是传递一个指针,就像在C中传递指针一样,只是编译器会为你插入适当的free调用。如果你传递引用,那也只是传递一个指针。因此,在传递所有权和借用引用之间没有性能损失或真正的性能差异,它们在编译后实际上是相同的。当然,如果你显式地复制一些内存,编译器会为你复制内存。

Rust代码示例分析

现在,我们通过一些Rust代码示例来分析所有权和借用。对于每个例子,请思考:这段代码能编译吗?如果不能,如何修复?如果等价的C/C++代码可以编译,为什么Rust不允许?在C中编写这类代码会出现什么问题?

示例1:不可变变量的修改

let s = String::from("hello");
s.push_str(", world!");

这段代码无法编译。我们有一个不可变变量s,它没有用mut关键字声明。当我们执行s.push_str时,我们试图修改这个字符串,但s被声明为不可变的,因此不允许。Rust编译器会报错:cannot borrow as mutable because it was declared immutable

如果加上mut关键字:

let mut s = String::from("hello");
s.push_str(", world!");

这样就能编译了。Rust允许我们修改这个可变字符串。

示例2:所有权转移

fn main() {
    let s = String::from("hello");
    amnomnom(s);
    amnomnom(s);
}

fn amnomnom(s: String) {
    println!("{}", s);
}

这段代码无法编译。第一次调用amnomnom(s)时,我们将s的所有权转移给了函数。函数执行完毕后,由于它拥有所有权,Rust会在函数结束时释放字符串。然后我们尝试第二次调用amnomnom(s),但此时s的所有权已经转移,内存也已被释放,因此无法再次使用。

在C语言中,你可能以多种方式编写此代码,但只有一种方式是正确的(在适当的位置释放内存)。Rust迫使你明确意图,不允许你含糊不清或犯错误,比如使用已释放的内存或双重释放。

为了保留所有权并在主函数中释放,我们可以传递引用:

fn main() {
    let s = String::from("hello");
    amnomnom(&s);
    amnomnom(&s);
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/69e0baa6ef3dd584d596b134dc202e6c_33.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/69e0baa6ef3dd584d596b134dc202e6c_34.png)

fn amnomnom(s: &String) {
    println!("{}", s);
}

这样,我们只是借用了s的引用,所有权仍保留在main函数中,字符串将在main函数结束时被释放。

示例3:多个不可变引用

let s = String::from("hello");
let s1 = &s;
let s2 = &s;
println!("{} {} {}", s, s1, s2);

这段代码可以编译。s是不可变的,s1s2是对s的不可变引用。因为所有内容都是不可变的,所以同时存在多个引用是完全可以的,不会产生问题。

示例4:可变引用

let mut s = String::from("hello");
let s1 = &mut s;
s1.push_str(", world!");

这段代码可以编译。我们有一个对可变字符串s的可变引用s1。Rust的规则是:你可以有多个不可变引用,但最多只能有一个可变引用。

示例5:混合可变与不可变

let s = String::from("hello");
let s1 = &mut s;
s1.push_str(", world!");

这段代码无法编译。s被声明为不可变(默认),但我们试图获取它的可变引用s1。如果允许,你将能够通过s1修改字符串,即使它被声明为不可变,这显然是不好的。

示例6:多个可变引用?

let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &mut s;
println!("{} {}", s1, s2);

这段代码无法编译。你试图创建两个指向同一数据的可变引用s1s2。Rust不允许这样做,因为同时存在多个可变引用会导致数据竞争。

示例7:非重叠的可变引用

let mut s = String::from("hello");
let s1 = &mut s;
println!("{}", s1);
println!("{}", s);

这段代码可以编译。Rust编译器非常智能,它会检查引用的生命周期。在这里,s1的可变引用在第一个println!后最后一次被使用,之后我们才使用s。因为ss1的生命周期内没有被使用,所以Rust认为这是安全的。本质上,你在借用后归还了引用。

Rust中的错误处理

现在,让我们转向错误处理。Rust处理错误的方式与C/C++非常不同。

考虑以下C代码:

char* buf = malloc(len);
memcpy(buf, packet, len);
// ... 处理数据 ...
free(buf);

这段代码存在安全漏洞。如果malloc失败(例如,请求的内存过大),它会返回一个空指针。但代码没有进行错误检查,会继续使用buf,导致空指针解引用和段错误。这是一种拒绝服务攻击。

这里有两个问题:

  1. 使用NULL作为真实值的替代品,使得可能将buf误认为是有效指针。
  2. 没有良好的机制来指示出了什么问题。

空指针已经导致了大量问题。空指针的发明者称其为“十亿美元的错误”,因为它看似微不足道,却给软件带来了巨大的痛苦。

空指针之所以危险,是因为它们给程序员带来了巨大的负担,需要跟踪什么可以为空、什么不能为空。在C语言中,NULL被广泛用作参数和返回值,程序员必须时刻保持警惕,进行适当的错误检查。但历史证明,我们无法始终成功地做到这一点。

Rust通过引入Option类型来解决这个问题。Option可以是Some(value)NoneNone相当于Rust中的空值,但关键区别在于,Option类型明确区分了有效值和空值,而不是将它们混在一起。

例如,一个返回Option<String>的函数:

fn feeling_lucky() -> Option<String> {
    // ... 可能返回 Some("I'm feeling lucky".to_string()) 或 None
}

要处理返回值,你可以检查它是否是SomeNone

let message = feeling_lucky().unwrap_or("not lucky".to_string());

或者,更地道的方式是使用match语句:

let message = match feeling_lucky() {
    Some(msg) => msg,
    None => "no message returned".to_string(),
};
println!("{}", message);

这样,Rust迫使你思考如何处理潜在的空值。编译器不会让你忽略它。

对于错误处理,Rust使用Result类型,它与Option非常相似,但用于表示操作的成功或失败,并可以携带错误信息。

总结

本节课我们一起学习了Rust中的所有权和错误处理核心概念。我们看到了所有权在C语言中同样存在且复杂,而Rust通过编译器强制在编译时解决所有权问题,避免了内存错误。在错误处理方面,Rust用OptionResult类型明确区分了有效值和错误状态,取代了容易出错的空指针,迫使程序员安全地处理所有可能的失败情况。虽然这些概念初学可能令人沮丧,但它们能帮助你编写更安全、更健壮的系统程序。

004:面向对象的Rust

在本节课中,我们将一起使用Rust实现一个链表。这是一个很好的实践机会,我们将接触到所有权、借用、Box智能指针以及面向对象编程等核心概念。

概述

我们将从定义链表节点和链表结构体开始,然后逐步实现构造函数、查询方法以及修改链表的方法(如pushpop)。在整个过程中,我们会看到Rust的所有权和借用规则如何确保内存安全。

定义节点和链表结构体

首先,我们需要定义链表的基本构成单元——节点。在Rust中,我们使用struct来定义结构体。

struct Node {
    value: u32,
    next: Option<Box<Node>>,
}

这里,value字段存储节点的值,我们暂时使用u32类型。next字段是一个Option<Box<Node>>,表示它可能包含一个指向下一个节点的Box智能指针,也可能是None(表示链表结束)。使用Box是因为我们需要在堆上分配节点,并且每个节点需要拥有其下一个节点。

接下来,我们定义链表本身,它包含一个头节点和记录大小的字段。

struct LinkedList {
    head: Option<Box<Node>>,
    size: usize,
}

head字段也是一个Option<Box<Node>>,因为链表可能为空。size字段记录链表中的元素数量。

实现构造函数

现在,让我们为NodeLinkedList实现构造函数(new方法)。

首先为Node实现:

impl Node {
    pub fn new(value: u32, next: Option<Box<Node>>) -> Node {
        Node {
            value: value,
            next: next,
        }
    }
}

Node::new函数接受一个值和一个next选项,然后返回构造好的Node实例。注意,函数体最后没有分号,这意味着该表达式的结果(即新创建的Node)将作为返回值。

接着为LinkedList实现:

impl LinkedList {
    pub fn new() -> LinkedList {
        LinkedList {
            head: None,
            size: 0,
        }
    }
}

LinkedList::new函数创建一个空的链表,头节点为None,大小为0。

实现查询方法

有了基本结构后,我们可以实现一些查询方法,例如获取大小和判断是否为空。

以下是get_size方法的实现:

impl LinkedList {
    pub fn get_size(&self) -> usize {
        self.size
    }
}

方法第一个参数是&self,表示这是一个不可变引用,该方法不会修改链表。它直接返回size字段的值。

类似地,实现is_empty方法:

impl LinkedList {
    pub fn is_empty(&self) -> bool {
        self.size == 0
        // 也可以写成:self.head.is_none()
        // 或者:self.get_size() == 0
    }
}

实现修改方法:push

现在,我们来实现一个能修改链表的方法——push,它将在链表头部添加一个新元素。

impl LinkedList {
    pub fn push(&mut self, value: u32) {
        let new_node = Box::new(Node::new(value, self.head.take()));
        self.head = Some(new_node);
        self.size += 1;
    }
}

让我们分解一下:

  1. &mut self:这是一个可变引用,因为push方法需要修改链表。
  2. self.head.take():这是关键的一步。take方法作用于Option上,它会取出self.head当前的值(一个Option<Box<Node>>),并在原位置留下None。这有效地转移了旧头节点的所有权,使我们能够将其作为新节点的next
  3. 我们创建一个新的Box<Node>,其next指向旧的头部。
  4. 将链表的head更新为这个新节点。
  5. size加1。

实现修改方法:pop

接下来,我们实现pop方法,它移除并返回链表头部的元素。

impl LinkedList {
    pub fn pop(&mut self) -> Option<u32> {
        let node = self.head.take()?;
        self.head = node.next;
        self.size -= 1;
        Some(node.value)
    }
}

分解步骤:

  1. self.head.take()?:再次使用take获取当前头节点。?操作符是Rust的错误处理语法糖。如果take()的结果是None(即链表为空),则?会提前从函数返回None。如果是Some(node),则node被解包出来,类型为Box<Node>
  2. 将链表的head更新为被移除节点的next节点。
  3. size减1。
  4. 返回被移除节点的值,包装在Some中。

测试链表功能

让我们编写一些代码来测试我们实现的链表。

fn main() {
    let mut list = LinkedList::new(); // 必须声明为mut
    for i in 1..10 {
        list.push(i);
    }
    list.display(); // 假设有一个display方法用于打印链表
    println!("List size: {}", list.get_size());
    if let Some(top_value) = list.pop() {
        println!("Popped value: {}", top_value);
    }
    println!("List size after pop: {}", list.get_size());
    list.display();
}

这段代码会创建一个链表,依次压入1到9,然后弹出头部元素。你可以观察到链表大小和内容的变化。

总结

本节课中,我们一起使用Rust实现了一个简单的链表。我们学习了:

  • 如何使用struct定义结构体。
  • 如何使用Box<T>在堆上分配内存并拥有其数据。
  • 如何使用Option<T>优雅地处理可能为空的值。
  • 如何为结构体实现方法(impl块),并区分不可变方法(&self)和可变方法(&mut self)。
  • 关键方法take()的使用,它允许我们通过可变引用取得Option内部值的所有权。
  • ?操作符在错误处理中的便捷应用。

这个例子综合运用了所有权、借用和生命周期等Rust核心概念,是理解Rust内存安全模型的绝佳实践。你可以基于此代码进一步探索,例如实现迭代器、使其支持泛型等。

005:Trait与泛型

在本节课中,我们将学习Rust中两个核心概念:Trait(特质)和泛型。Trait用于定义共享的行为,而泛型允许我们编写可复用于多种数据类型的代码。我们将通过具体的代码示例来理解这些概念,并学习如何在实际编程中应用它们。


分组相关功能

在C++和Java等语言中,我们通常使用抽象类或接口来分组相关功能。例如,一个“形状”抽象类可能定义了计算面积和周长的抽象方法。在Rust中,我们使用Trait来实现类似的功能。Trait定义了一个类型可以做什么,例如,如果一个类型实现了Display特质,它就知道如何以可读的格式展示自己。

什么是Trait?

Trait是Rust中定义共享行为的一种方式。它们类似于其他语言中的接口,但功能更强大。通过Trait,我们可以定义一组方法签名,任何实现该Trait的类型都必须提供这些方法的具体实现。

以下是一些常见的标准库Trait:

  • Display: 用于格式化输出,例如在println!宏中使用。
  • Debug: 用于调试输出,通常通过#[derive(Debug)]自动派生。
  • Clone / Copy: Clone允许显式复制数据,Copy则改变了赋值运算符=的语义,使其执行复制而非所有权转移。
  • Drop: 定义值离开作用域时发生的清理行为。
  • PartialEq / Eq: 用于定义相等性比较。

实现Trait

让我们通过一个链表例子来看如何实现Trait。我们为链表实现Display特质,使其能够被打印。

impl fmt::Display for LinkedList {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let mut result = String::new();
        let mut current = &self.head;
        while let Some(node) = current {
            result = format!("{} {}", result, node.val);
            current = &node.next;
        }
        write!(f, "{}", result)
    }
}

实现之后,我们就可以直接使用println!("{}", list);来打印链表了。

上一节我们介绍了如何手动实现Trait,本节中我们来看看如何让编译器自动为我们生成一些常用Trait的实现。

派生Trait

对于许多简单的结构体,Rust编译器可以自动为我们派生(derive)一些Trait的实现,这通过#[derive(...)]属性实现。

#[derive(Debug, PartialEq, Clone, Copy)]
struct Point {
    x: f64,
    y: f64,
}

以下是可以通过#[derive]自动派生的部分Trait:

  • Debug
  • PartialEq, Eq
  • PartialOrd, Ord
  • Clone
  • Copy
  • Hash
  • Default

定义自己的Trait

我们不仅可以实现现有的Trait,还可以定义自己的Trait来描述特定领域的行为。

pub trait ComputeNorm {
    fn compute_norm(&self) -> f64 {
        // 默认实现
        0.0
    }
}

然后,我们可以为不同的类型实现这个Trait:

impl ComputeNorm for Point {
    fn compute_norm(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }
}

impl ComputeNorm for Vec<f64> {
    fn compute_norm(&self) -> f64 {
        self.iter().map(|x| x * x).sum::<f64>().sqrt()
    }
}

使用Trait重载运算符

Trait还可以用来重载运算符。例如,我们可以为Point类型实现加法运算符+

use std::ops::Add;

impl Add for Point {
    type Output = Self;

    fn add(self, other: Self) -> Self::Output {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

实现之后,我们就可以使用let p3 = p1 + p2;这样的语法了。

泛型

泛型允许我们编写不依赖于具体数据类型的代码,从而提高代码的复用性。你已经使用过泛型了,例如Vec<T>Option<T>

定义泛型结构体

我们可以定义自己的泛型结构体。

struct MatchingPair<T> {
    first: T,
    second: T,
}

impl<T> MatchingPair<T> {
    fn new(first: T, second: T) -> Self {
        MatchingPair { first, second }
    }
}

Trait约束

泛型常常与Trait约束(Trait Bound)结合使用,以限制泛型类型参数必须实现某些特定行为。

impl<T: fmt::Display> fmt::Display for MatchingPair<T> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.first, self.second)
    }
}

上面的代码为所有实现了Display特质的类型TMatchingPair<T>实现了Display特质。这意味着只要T能被显示,MatchingPair<T>就能被显示,我们无需为每种具体的T(如chari32String)单独实现。


本节课中我们一起学习了Rust中Trait和泛型的核心概念。Trait是定义共享行为的强大工具,允许我们以灵活的方式组织代码功能。泛型则让我们能够编写可复用的代码,而Trait约束确保了泛型代码的类型安全。结合使用Trait和泛型,可以极大地提升Rust代码的表达力和抽象能力。在接下来的课程中,我们将继续探索这些概念在更复杂场景中的应用。

006:智能指针 🧠

在本节课中,我们将要学习Rust中的智能指针。我们将首先回顾并完成上一讲关于特质(Traits)和泛型(Generics)的讨论,然后深入探讨几种不同类型的智能指针,包括 Box<T>Rc<T>RefCell<T>。这些工具对于管理堆内存和实现复杂的数据结构至关重要。

回顾特质与泛型

上一节我们介绍了特质和泛型的基础知识,本节中我们来看看如何在实际代码中应用它们,并理解它们如何实现零成本抽象。

实现 Clone 特质

我们从一个名为 MatchingPair 的结构体开始,它有两个相同类型 T 的字段。

struct MatchingPair<T> {
    first: T,
    second: T,
}

现在,我们想为 MatchingPair 实现 Clone 特质。Clone 特质有一个名为 clone 的函数,它接受一个对自身的不可变引用(&self),并返回一个自身的副本。

impl<T> Clone for MatchingPair<T> {
    fn clone(&self) -> Self {
        MatchingPair::new(self.first.clone(), self.second.clone())
    }
}

这里的关键点是,我们不能直接从 &self 中移出(move)firstsecond 字段,因为 self 是一个共享引用。我们必须调用字段上的 clone 方法。这意味着类型 T 本身必须实现 Clone 特质。我们通过特质约束(trait bound)来声明这一点。

impl<T: Clone> Clone for MatchingPair<T> {
    fn clone(&self) -> Self {
        MatchingPair::new(self.first.clone(), self.second.clone())
    }
}

如果 T 没有实现 Clone,编译器会报错,告诉我们相应的特质约束未得到满足。

泛型函数与特质组合

泛型也可以用在函数中。以下是一个简单的恒等函数:

fn identity<T>(x: T) -> T {
    x
}

我们还可以为泛型参数添加特质约束。例如,一个函数要求其参数同时实现 DisplayPartialOrd 特质:

use std::fmt::Display;

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/ec55f5672d2955956bd033a9cf70e52d_7.png)

fn print_min<T: Display + PartialOrd>(x: T, y: T) {
    if x < y {
        println!("最小值是: {}", x);
    } else {
        println!("最小值是: {}", y);
    }
}

这里的 + 符号表示特质组合(trait composition),意味着类型 T 必须同时满足多个特质。

零成本抽象与真实案例

Rust 的特质和泛型是零成本抽象(zero-cost abstractions)。这意味着它们提供了高级的编程特性,但在运行时几乎没有额外开销。编译器通过静态分发(static dispatch)在编译时为每个具体类型生成特化的代码。

这种能力在系统编程中非常有用。例如,嵌入式操作系统 Tock 就大量使用了特质来定义其内核组件(如传感器驱动)和系统调用接口,证明了即使在最底层的代码中,也能安全地使用这些高级抽象。

智能指针介绍

现在,让我们转向本节课的核心主题:智能指针。智能指针是一种数据结构,它不仅像普通指针一样指向数据,还拥有额外的元数据和功能,如所有权和借用规则管理。

Box<T>:唯一指针

Box<T> 是最简单的智能指针。它在堆上分配内存,并拥有该内存的唯一所有权。当 Box 离开作用域时,它会自动释放其指向的堆内存。

let b = Box::new(5); // 在堆上分配一个整数

Box<T> 的局限性在于它是唯一指针。你只能有一个所有者,这限制了它在需要多个引用共享同一数据场景下的使用,例如在图或双向链表中。

Rc<T>:引用计数指针

当需要多个所有者共享同一块堆内存时,可以使用 Rc<T>(Reference Counted pointer)。Rc<T> 通过引用计数来追踪所有者的数量。当计数变为零时,内存会被自动释放。

use std::rc::Rc;

let a = Rc::new(5);
let b = Rc::clone(&a); // 增加引用计数

Rc<T> 只允许不可变(共享)引用。这意味着你不能通过 Rc 直接修改其内部数据。这遵守了 Rust 的借用规则:要么多个不可变借用,要么一个可变借用。

持久化数据结构示例

Rc<T> 非常适合实现持久化数据结构(persistent data structures)。在这种数据结构中,操作(如添加元素)不会修改原数据,而是返回一个新版本的数据,同时共享未改变的部分。

以下是使用 Rc 实现持久化链表的 push 方法示例:

use std::rc::Rc;

struct Node<T> {
    value: T,
    next: Option<Rc<Node<T>>>,
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/ec55f5672d2955956bd033a9cf70e52d_14.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/ec55f5672d2955956bd033a9cf70e52d_16.png)

struct PersistentList<T> {
    head: Option<Rc<Node<T>>>,
    size: u32,
}

impl<T> PersistentList<T> {
    fn push_front(&self, value: T) -> Self
    where
        T: Clone,
    {
        let new_node = Rc::new(Node {
            value,
            next: self.head.clone(), // 克隆 Option<Rc<Node<T>>>,增加引用计数
        });
        PersistentList {
            head: Some(new_node),
            size: self.size + 1,
        }
    }
}

通过这种方式,我们可以创建链表的不同“版本”,它们共享底层节点,而无需复制整个数据结构。

Rc<T> 的警告:循环引用
Rc<T> 可能导致内存泄漏,如果创建了循环引用。例如,对象 A 持有 Rc 指向 B,同时 B 也持有 Rc 指向 A,那么它们的引用计数永远不会降到零,内存也就永远不会被释放。解决这个问题需要更复杂的模式,如使用 Weak<T>

RefCell<T>:内部可变性

RefCell<T> 提供了“内部可变性”(Interior Mutability)。它允许你在拥有不可变引用的情况下,仍然能够修改其内部数据。这是通过在运行时强制执行借用规则来实现的:在任意时刻,只允许一个可变借用或多个不可变借用。如果违反规则,程序会 panic(恐慌)。

use std::cell::RefCell;

let data = RefCell::new(5);
{
    let mut borrow = data.borrow_mut(); // 获取可变借用
    *borrow += 1;
} // 借用在此处离开作用域,被释放
println!("{}", data.borrow()); // 获取不可变借用

RefCell<T> 本身不分配堆内存。它通常与 Rc<T> 结合使用,以实现具有多个所有者且可修改的数据结构。

use std::rc::Rc;
use std::cell::RefCell;

let shared_data = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone1 = Rc::clone(&shared_data);
clone1.borrow_mut().push(4); // 通过 Rc<RefCell<Vec<i32>>> 修改数据

这种 Rc<RefCell<T>> 模式非常强大,常用于实现如双向链表等需要多处修改和共享所有权的数据结构。

总结

本节课中我们一起学习了 Rust 智能指针的核心概念。

  • 我们首先完成了对特质和泛型的讨论,看到了如何通过特质约束和组合来编写可重用且类型安全的代码,并了解了零成本抽象的优势。
  • 接着,我们深入探讨了三种主要的智能指针:
    • Box<T>:用于在堆上分配数据的唯一所有权指针。
    • Rc<T>:引用计数指针,允许多个所有者共享不可变数据,是实现持久化数据结构的利器。
    • RefCell<T>:提供内部可变性,在运行时检查借用规则,常与 Rc 搭配使用以实现共享且可变的数据。

智能指针是 Rust 所有权和借用系统的重要组成部分,它们使得在保证内存安全的前提下,构建复杂的数据结构成为可能。理解它们的工作原理对于成为一名高效的 Rust 程序员至关重要。

007:多进程编程的陷阱 🚧

在本节课中,我们将要学习多进程编程中常见的陷阱,特别是关于fork系统调用的使用。我们将探讨为什么直接使用fork可能带来风险,并介绍更安全、更高级的抽象方法。


大家好,欢迎来到第四周。本周我们将讨论多进程编程。你们已经完成了本季度近一半的课程,这非常了不起。无论你们感觉如何,我认为你们应该为取得的进步感到自豪。现在,你们已经掌握了足够的Rust知识,可以开始构建实际项目了。从本周开始,我们将更多地讨论CS 110课程的内容,以及它们如何与系统编程的安全性和健壮性相关联。我们将从多进程编程开始。

希望你们一切安好,保持健康,保持理智,并祝贺你们取得目前的成就。

课程安排 📅

上周发布的练习将于周三截止。如果你们需要帮助或感到困惑,请随时联系我们。我们知道有些人可能对特质(traits)和特质边界(trait bounds)等内容不太熟悉。你们可以随时提出问题,例如“我不太理解这部分内容,能为我提供一些额外资源吗?”或者“能和我一起讨论一下吗?”。我们非常乐意通过Zoom或Slack提供帮助。我们不希望你们在某个问题上花费过多时间,如果遇到困难,请及时联系我们。

另外,提醒一下,你们可以用一篇关于Rust学习经历的博客文章来替代任何一周的练习。文章内容可以是你们喜欢或不喜欢Rust的方面,或者任何相关主题。如果对此感兴趣,请记住这是一个可选方案。

第一个项目将于本周晚些时候(可能是周四或周五)发布。该项目是实现一个迷你调试器,截止日期为发布后的两周。你们可以与一位伙伴合作完成。如果你们对寻找合作伙伴有更好的建议,请告诉我。本周将没有练习,只有一项调查,因为项目即将发布。

为什么不应直接使用 fork? 🤔

今天,我们将讨论为什么不应使用我们在CS 110课程中一直推荐使用的fork系统调用。这可能会引起一些争议。我将尝试提出一些论据来解释为什么我认为应该这样做。然后,在周四,我们将通过一个案例研究来探讨Google Chrome如何使用多进程。我认为Google Chrome是目前最复杂的系统之一,它在某种程度上就像一个操作系统。我们将看看它如何利用进程来提高性能、安全性和健壮性。

首先,让我们谈谈fork。我将尝试论证,尽管我们在CS 110中告诉你们使用fork,但在你们的代码中不应直接调用它。那么,为什么我们会有fork呢?有人能提出一些fork有用的原因吗?例如,为什么我们想在代码中调用它?

fork 的用途

  • 调用系统上的其他功能:如果你想运行其他二进制文件并希望继续执行当前程序,那么你需要使用fork
  • 实现并发执行:如果只有一个进程和一个线程,你无法获得太多并发性。虽然可以安装信号处理程序,但除此之外,你无法同时执行多个任务。fork允许你同时执行多个函数或代码块。

我将尝试论证第一个理由(用于并发执行)并不是一个好理由。让我们从这个开始。

fork 用于并发执行的陷阱

假设你想同时运行多个任务,例如并发运行另一个函数或一段代码。我们如何可能搞砸这件事呢?

让我们打开一个编辑器,看看一个简单的例子:

pid_t pid = fork();
if (pid == 0) {
    // 子进程:执行一些并发任务
    // ...
} else {
    // 父进程:继续执行
    // ...
}

这段代码可能出错的地方有哪些?

  1. 忘记回收子进程(僵尸进程):如果你忘记调用waitpid,为什么这很糟糕?这是一个资源泄漏。当你调用fork时,你实际上是在分配内存(内核中的进程数据结构)。你需要调用waitpid来释放这些内存,否则内核将保留这些进程数据结构。如果你的程序运行很长时间,将会积累大量僵尸进程,最终可能导致系统无法创建新进程。

  2. 进程创建顺序错误:如果你想启动两个执行不同任务的子进程,可能会这样写:

    pid_t pid1 = fork();
    if (pid1 == 0) {
        // 子进程1的任务
    }
    pid_t pid2 = fork();
    if (pid2 == 0) {
        // 子进程2的任务
    }
    

    这里的第二个fork调用会产生两个子进程(父进程和第一个子进程都会执行它),最终你会得到四个进程,而不是预期的三个。因此,你必须非常小心代码的顺序。在CS 110的作业3中,这是一个非常常见的错误。

  3. 子进程意外执行父进程代码:在子进程分支中,我们必须确保在完成任务后返回或退出。否则,子进程将继续执行if语句之后的代码,这些代码原本是为父进程设计的。例如:

    if (pid == 0) {
        // 子进程任务
        return; // 必须返回或exit()
    }
    // 父进程代码
    

    如果其他开发者重构代码,将子进程任务移到一个函数中,但忘记在函数调用后返回,就会导致子进程执行父进程代码。

  4. 异常处理问题:在C++中,如果在子进程中使用execvp失败并抛出异常,而该异常被捕获并处理,子进程可能不会终止,从而继续执行父进程代码。

  5. 与多线程混合使用的危险:这是我认为最严重的问题。如果你在子进程中分配堆内存(例如使用malloc),而程序中有其他线程存在,那么fork时只有调用fork的线程会存活,其他线程会消失,但它们可能没有机会清理状态(例如,可能正在修改堆分配器的数据结构)。这可能导致子进程中的堆处于不一致状态,进而使malloc失败。即使你确定自己的代码没有使用多线程,你使用的库也可能在后台使用多线程。因此,在子进程中分配内存需要100%确定在调用fork时没有其他线程运行,这是一个很难保证的条件。

基于以上原因,我认为如果你想让程序的两个部分并发运行,应该将它们放在单独的可执行文件中。你应该从主程序fork一个子进程,然后调用execvp来运行另一个二进制文件。这样,即使malloc的数据结构已损坏,execvp也会丢弃整个虚拟内存空间并加载新的二进制文件,从而避免不一致状态。

forkexec 的设计哲学

那么,为什么操作系统设计者将forkexec分成两个系统调用,而不是合并成一个呢?原因在于灵活性。通过forkexec,你可以在调用exec之前,使用任何系统调用来定制子进程的环境,例如:

  • 重定向文件描述符
  • 修改环境变量
  • 将进程绑定到特定的CPU核心
  • 启用调试功能
  • 等等

Windows尝试将两者合并成一个CreateProcess系统调用,但它需要大量参数,甚至结构体作为参数,非常复杂。而forkexec几乎不需要参数,非常简单,但功能极其强大。然而,简单并不意味着易于使用。正因为其强大,也容易出错。

更安全的抽象:子进程库

那么,我们该怎么办呢?最好的方法是在forkexec之上构建一个更高级的抽象。许多编程语言都提供了这样的抽象:

  • Ruststd::process::Command
  • Pythonsubprocess 模块
  • C++ 也有类似的库

这些抽象允许你方便地创建子进程,并设置标准输入/输出/错误、环境变量、工作目录等。更重要的是,它们通常允许你指定一个“preexec”函数,该函数在fork之后、exec之前调用,让你可以执行那些抽象未涵盖的低级定制操作。

在Rust中,使用Command的语法非常直观:

use std::process::Command;

let output = Command::new("echo")
    .arg("Hello, world!")
    .output()
    .expect("Failed to execute command");

Command提供了几种运行子进程的方法:

  • .output(): 运行命令并等待完成,捕获其输出。
  • .status(): 运行命令并等待完成,返回退出状态。
  • .spawn(): 启动命令并立即返回一个Child句柄,之后可以调用.wait()来等待。

对于需要在exec前执行特定操作的情况,可以使用.pre_exec()方法,但需要注意,其中的代码应避免分配内存,并且被标记为unsafe,因为它在一个潜在危险的环境中运行。

管道的陷阱 🚰

上一节我们介绍了fork的陷阱和更安全的抽象,本节中我们来看看另一个多进程编程中常用的工具——管道(pipe)可能遇到的问题。

管道用于进程间通信,但使用它们时也可能遇到问题:

  • 文件描述符泄漏:忘记关闭管道的一端。
  • 关闭错误的文件描述符:例如,在错误检查时误关了标准输入。
    if (close(fd) == -1) { // 正确:检查close的返回值
        // 处理错误
    }
    if (close(fd) == -1)   // 错误:括号位置错误,实际上在检查fd是否为-1
        perror("close");
    
  • 使用未初始化的管道:在调用pipe之前就使用了管道文件描述符数组中的值。

解决方案同样是使用更高级的抽象。例如,可以创建一个封装了管道的对象,并在其析构函数中自动关闭文件描述符,从而避免手动管理带来的错误。

为什么还要学习底层机制? 🧠

你们可能会想:既然直接使用forkexecpipe和信号有这么多陷阱,为什么CS 110还要教我们使用它们呢?我的观点是,理解这些底层机制的工作原理至关重要。尤其是在系统编程中,当你使用高级抽象库时,你需要知道它们底层在做什么。因为系统编程中的交互非常复杂,交换两行代码可能完全改变程序的行为。如果你需要实现这些抽象库,或者调试它们的问题,你必须理解底层机制。我们构建抽象是为了让你们避免犯错,但为了有效地使用和调试这些抽象,你们需要理解它们是如何工作的。

信号安全代码分析 ⚠️

在剩余的时间里,我们将进行一个小组练习。请访问课程网站,查看今天讲座的笔记,那里有四个关于信号处理的代码示例。其中一些是安全的,一些则不安全。请在小组中讨论你们认为哪些示例是安全的,并思考原因。由于时间关系,我们将在周四的讲座开始时回顾这些示例,并解释为什么结果可能出乎意料。


本节课中我们一起学习了多进程编程中fork系统调用的潜在陷阱,包括资源泄漏、执行流混淆以及与多线程混合使用的危险。我们探讨了为什么直接使用fork进行并发执行可能不是最佳实践,并介绍了通过创建独立可执行文件和使用高级抽象(如Rust的Command)来更安全地创建子进程的方法。我们还简要讨论了管道的常见问题及其解决方案。最后,我们强调了理解底层系统调用原理对于有效使用高级抽象和进行系统调试的重要性。

008:信号处理的陷阱与Google Chrome案例研究

在本节课中,我们将学习信号处理中的常见陷阱,并通过Google Chrome浏览器的案例研究,探讨多进程架构如何解决复杂系统中的安全与稳定性问题。


课程概述

欢迎来到第四周的周四,我们即将完成本季度一半的课程。首先是一些关于项目的快速说明。我们将在明天发布迷你GDPB项目。在这个项目中,你将实现一个非常简单的GDP版本,它实际上包含了GDP的大部分核心功能,例如设置断点和单步执行代码。这将是一个非常有趣的项目。同时,我们也提醒你,你可以提出自己的项目想法,并研究任何你感兴趣的内容。

一些潜在的想法包括:几周前有人询问是否有工具可以帮助理解Rust编译器在做什么,特别是是否有工具可以看到编译器何时丢弃一个值。答案是,目前没有这样的工具。但如果你想实现类似的功能,我们可以为你提供一些指导。如果你对特定领域感兴趣,可以自由地在Rust中实现与该领域更相关的内容。例如,如果你上过CS148并对计算机图形学感兴趣,也许你可以实现一个光线追踪器。在本季度后期,当我们开始讨论多线程时,你可以让你的光线追踪器变得非常快。你也可以选择一个像grep这样的命令行工具,并尝试超越其性能。有一个基于Rust的程序叫ripgrep,它比grep快得多,这可能是一个有趣的挑战。如果你对数据库感兴趣,可以尝试实现一个简单的数据库。任何你能想到的想法,都可以给我们发消息询问,例如“这个想法合理吗?”或者“你认为我能在两周多的时间里完成这个吗?”,我们很乐意给你一些反馈或指引正确的方向。

在继续之前,关于项目有任何问题吗?很好。

那么,快速概述一下今天的内容。我们将从上周结束的地方开始,继续讨论信号处理。然后,我们将做一个简短的案例研究,探讨Google Chrome如何使用进程。我认为这将非常令人兴奋。这是一个非常有趣的项目,有许多不同的约束和目标。我们将讨论他们如何使用多进程来支持这些目标。


信号处理的陷阱

首先,我们上次讲到“不要调用signal”,这听起来可能有些争议,因为我们在CS110课程中花了很长时间,并且你们即将在项目中花费大量时间处理信号处理和并发问题。为什么我要告诉你们不要调用signal呢?如果你查看man手册页,它会告诉你不要使用signal。手册页指出,其行为在不同的Unix版本和Linux版本之间各不相同,不要使用signal,并建议查看下面的“可移植性”部分。如果你查看这个可移植性部分,它会说signal唯一可移植的用途是忽略信号或恢复默认处理程序。因此,signal用于这些目的是可以的,但如果你想设置一个信号处理程序,就不要调用signal。有一个不同的函数叫sigaction

你应该调用sigaction。手册页中提到了语义的混乱,以及对语义的显式控制。什么是语义?在这个上下文中,语义指的是信号处理的细节。例如,假设你收到了一个SIGCHLD信号,这启动了你的SIGCHLD处理程序。然后,当你在处理程序中间时,又收到了另一个SIGCHLD信号。你是重新启动处理程序,还是立即再次调用处理程序?或者,你是否会阻塞SIGCHLD直到处理程序运行完毕,然后再运行它?这就是信号处理语义的一个例子,而signal实际上并未指定这一点。但sigaction有一系列选项,允许你指定类似的行为。我们在讲座中不讨论sigaction的原因是,如果你查看它的接口,由于需要指定所有这些选项,在白板上画出来会非常难看。但我们在作业的起始代码中使用了sigaction,你会看到一个名为install_signal_handler的函数,它调用了sigaction。如果你在自己的C/C++项目中使用信号,你应该意识到不应该调用signal

这个man手册页实际上非常有趣,它详细讨论了导致这种可移植性混乱的历史遗留问题。如果你想了解更多,这是一篇相当有趣的阅读材料。

好了,上次我给出了四个代码示例,它们要么是安全的,要么是不安全的。让我们快速过一遍,我想听听你们的想法。你们认为这个示例是安全还是不安全?

while (1) {
    // 无限循环
}
// 当收到Ctrl+C (SIGINT)时,直接退出

是的,这个看起来很简单,对吧?这里没有太多事情发生,它无限循环,然后每当收到Ctrl+C时,它就退出。所以,是的,我认为这个是安全的。

那么这一个呢?这个示例计算接收到的SIGCHLD信号的数量。上节课结束时有人提到,如果你希望处理程序调用waitpid来等待所有子进程,你可能应该在处理程序内部使用一个while循环,以便在waitpid返回大于零时继续调用。这样你就能获取所有子进程,因为正如我提到的,两个SIGCHLD信号可能同时发送,如果父进程同时接收到它们,它只会调用一次处理程序。所以,如果你在处理程序中调用waitpid,你可能需要多次调用它,以便获取所有同时生成信号的子进程。但这不是本示例的目的。

这个示例只是为了计算传入的SIGCHLD信号的数量。最后,你可以将其与进程数量进行比较,几乎总是会发现打印的第二个数字小于第一个数字。接收到的SIGCHLD信号数量少于退出的进程数量。这里没有正确性问题。我想知道这里是否存在安全问题,这段代码安全吗?通常是什么导致竞态条件?

没错,数据竞争通常是由于某个共享资源在多个地方同时使用而引起的。所以,这是我们的共享资源,它是唯一的全局变量。sigchild_count在哪里被使用?它在printf中被使用,还在另一个地方被使用。也就是处理程序中。所以,有两个地方在使用这个变量。这两个地方有可能同时使用这个变量吗?

是的,由于这里的waitpid,这种情况不可能同时发生。这个waitpid将等待所有子进程退出。而你在子进程退出时会收到SIGCHLD信号。所以,在这个while循环之后,直到这一点,你都可以收到SIGCHLD信号。在这个点之后,你已经对所有子进程调用了waitpid,所有子进程都已终止,所有SIGCHLD信号都已到达,之后就不会再有信号了。所以,当你到达这个printf时,这个处理程序将不再运行。因此,虽然有两个地方使用这个变量,但它们在时间上是分开的,不会同时使用。所以,只要我们在使用sigaction来指定处理程序不能同时运行多次(即处理程序不能被自身中断),这个示例实际上是没问题的。在CS110中讨论信号语义时,我们通常使用这些语义。

那么,这个示例呢?在这个示例中,我们试图计算正在运行的进程数量。所以,在这个循环中,我们fork了一堆进程并递增一个计数器,然后在SIGCHLD处理程序中递减该计数器,最后打印正在运行的进程数量。安全还是不安全?有人想猜一下吗?

好的。我们在两个不同的地方进行递增和递减操作,你说这感觉很奇怪,为什么?是的。这里的sleep(1)调用有点打乱了节奏,但理论上,问题不在于你在不同地方有递增和递减操作,而在于理论上它们可能同时发生。所以,你可能在这里递减的同时,在这里递增。假设因为我们这里有sleep(1)调用,我们有一个合理的操作系统调度器,它不会在一秒后才调度这一行,比如这个for循环的运行不会有一秒的延迟。那么,假设这个递增和这个递减不可能同时发生。这能解决我们的安全问题吗?

好的。我们可能会有一个潜在的死锁场景:如果我们检查running_processes大于零,但紧接着在这个while循环之后,就在这两行代码之间,在我们检查它大于零之后但在pause之前,一个SIGCHLD信号到来,这会将running_processes递减到零。那么,我们就不应该暂停,因为所有子进程都已退出,但我们已经决定运行while循环内部的代码,所以接下来我们做的就是pause,然后我们就无限期地死锁了。这绝对是一个安全问题。

这是唯一的安全问题吗?这里还有一些非常简单的问题。在同一个地方。是的,请讲。这可能不明显,因为这只是一个简单的整数。但从形式上讲,在可能被修改的同时读取这个值并不被认为是安全的。这是有可能的,所以读取这个值并将其与零比较发生在多个汇编指令中,有可能在这个过程中,这个处理程序被调用(抱歉,是这个处理程序被调用)并且值被更改。在这里,这没什么大不了的,因为它只是一个简单的整数,在x86架构上,这没问题。但从形式上讲,这种行为是未定义的。如果我们在不同的架构上运行,我们真的不确定会发生什么。如果这不仅仅是一个简单的int,而是一个正在运行的进程列表,例如,然后在我们试图查看该列表时,该列表被修改,列表可能在我们试图查看时被重新分配,现在我们可能正在查看无效的内存。所以,你绝对不希望在一个值可能被更改的同时读取它。

因此,在这个main函数中使用running_processes的所有地方,我们都需要确保处理程序不会运行,方法是使用sigprocmask来阻塞SIGCHLD。然后在这里,当我们使用这个pause时,我们必须使用sigsuspend来代替,正如在110课程中讨论的那样,以避免死锁,即信号在你检查条件之后但在你进入睡眠之前到来的潜在死锁。这对大家都清楚吗,大拇指向上还是向下?

我想确保我有时间讲完剩下的幻灯片,所以我将在Slack上回答那个问题,但本质上sigsuspend... 是的,我将在Slack上回答,因为这是一个非常好的问题,而且很重要。关于这里发生的事情还有其他主要问题吗?

好的,这是整个季度我最喜欢的例子,这个安全还是不安全?我通过如此关注它已经给出了答案。所以,如果你从CS110的角度来看这个,你应该说这是安全的,就像Ryan说的,这是你展示的四个例子中第二简单的。你所做的只是,当按下Ctrl+C时,它打印“he he not exiting”而不是实际退出。这看起来没有任何问题。你只是在打印,对吧?这太简单了。能出什么问题呢?我说这实际上不安全。你会想,这怎么可能不安全呢?我认为这里任何事情都可能发生。我们不知道,它可能崩溃,可能死锁。为了向你演示,我写了一个这个例子的扩展版本。所以,不仅仅是打印一个小字符串,而是打印一个非常长的字符串。我把“hello world”重复了一千次。但在这个while true循环中,我只是反复打印“hello world”一千次。这就是这里发生的一切。然后同时,我产生一个子进程,这个子进程在一个while true循环中,反复向父进程发送SIGUSR1信号。SIGUSR1处理程序做什么?我安装了这个信号处理程序,它只是调用printf("hello world")。这就是这里发生的一切。真的,真的,非常简单,和我们在这里做的完全一样。

如果你运行这个程序,欢迎你运行它。它会死锁。你会想,什么?或者如果你运行它,有时你会得到一个SIGABRT,然后它崩溃了。你会想,什么?然后更奇怪的是,偶尔,这只发生在我身上几次,你运行它,甚至在它有机会打印任何东西之前,你就得到了“非法硬件指令”。这正是我刚才在幻灯片上展示的代码,你会想,哇,Ryan,等一下,这里发生了什么?

事实证明,你不能从信号处理程序中调用printf,这绝对不是一件安全的事情。要理解为什么,首先,printf是一个非常糟糕的函数。这是我一生中读过的最糟糕的代码之一,这个东西差不多有2000行。但在那2000行的中间,有一个对名为flock的系统调用。flock的作用是锁定一个打开的文件,它在打开文件表条目中设置一个位,表示“嘿,现在请不要写入这个文件”。原因是,如果系统上有多个进程同时打印,你不想让它们的输出混在一起。所以我们使用这种锁定技术,当你想打印时,你锁定标准输出,然后写入,当你写入时,没有其他进程能够打印到标准输出,没有其他进程能够写入标准输出,这样输出就不会混在一起。然后当它完成打印到标准输出时,它解锁文件描述符,其他进程就可以写了。

显然这里还有其他事情发生,它调用了mallocmalloc做了类似的事情,但由于这种全局状态的使用和这种锁定的使用,这最终导致了很多问题。一般来说,你不应该调用使用全局状态的函数。这里有一个安全函数列表,它很长,但也不是那么长。C语言中有很多你常用的函数不包含在这里,而C++中的大多数函数依赖于动态内存分配,例如,如果你使用stringvector,你绝对不能从信号处理程序中调用它们。

为了解释这里发生了什么,为什么这种锁定如此成问题,假设你正在main函数中打印一些非常长的内容,所以你经过了第1311行,并且你已经获得了这个文件的锁,你设置了表示该文件已锁定的位。然后就在那之后,信号到达,你进入信号处理程序,现在你开始在信号处理程序中运行printf。那么这个printf做什么?它运行完全相同的代码,所以它到达第1311行并尝试调用flock,但位已经设置,锁已经被获取,所以printf会说,哦,其他进程(实际上是同一个进程,但它认为是其他进程)正在打印标准输出,我需要等待那个进程退出。但当然,不是其他进程在写入printf,而是我们的进程,只是我们恰好在写入printf的过程中中断了它。所以你最终无限期地等待这个其他进程完成打印,导致死锁。

这清楚了吗?那么我们应该怎么做呢?printf看起来是如此基本的东西,如果我们甚至不能打印,那么我们该如何处理信号呢?如果在信号处理程序中我们能做的事情不多,那真的限制了我们的能力。那么,有人对我们应该怎么做,如何处理信号有什么想法吗?有什么创造性的想法吗?猜一下。我们仍然希望能够响应信号进行打印。只是我们不能在信号处理程序内部打印。修改进程中的某些东西。嗯哼。好的,非常好的想法。所以,与其在处理程序中做工作,我们不如在处理程序中拦截信号,然后在主进程中做实际的工作。我们如何进行这种通信呢?我们如何从在处理程序中捕获信号过渡到在主进程中做工作?我们如何向主进程指示它现在应该打印“Hihi not exiting”?是的,你可以有一个像signal_received这样的全局变量,然后在你的处理程序中,你可以设置这个变量,然后在你的主进程中,你可以检查这个变量是否被设置。如果它被设置了,那么我们就知道我们收到了SIGINT,你可以处理它。

如果你要做任何复杂的事情,或者你想处理多个信号,它会变得非常混乱,你需要设置多个变量,你必须处理取消设置变量,所以如果有人按了两次Ctrl+C会发生什么,你必须确保你的代码能够拦截这两个独立的Ctrl+C,比如一旦你在主代码中响应了Ctrl+C做了某事,你必须将该变量重置为零,并确保它可以再次被设置,等等。

有一个更聪明的技巧,有人在90年代初发明了,叫做“自管道技巧”。这是一个非常荒谬的黑客,但它有效,现在几乎到处都在使用。你要做的是创建一个管道。通常管道用于进程间通信,但在我们的情况下,我们将只使用它在我们自己的进程内进行通信。所以我们创建一个管道,然后当我们等待信号时,在你的主函数主代码中,你只是不断地从管道读取。所以这个读取调用,如果是在管道上调用,它会暂停,它会休眠直到有东西写入管道。所以它本质上会等待有东西写入管道。然后,在信号处理程序中,当你收到信号时,向管道写入一个字节。这样就会唤醒你的主代码,然后你的主代码会说,哦,我从管道里得到了东西,我一定收到了一个信号。如果你愿意,你可以为不同的信号写入不同的字节,以指示发生了不同的事情。但通过这样做,它比设置单个变量更简单,因为接收多个信号的语义也得到了处理。如果你写入两个字节,那么你可以从管道中读取两个字节,然后你可以看到,哦,我得到了两个字节,这意味着收到了两个信号。

这是一个丑陋的黑客。如果你觉得这很荒谬,我同意。但这实际上是信号通常的处理方式。并且有一个新的Linux库,它不新,但它是在POSIX之后引入的,增加了对这个黑客的支持。所以你可以做的是,首先阻塞你想要接收的信号,这样它们就不会被默认的信号处理程序处理。然后你创建这个signalfd东西,signalfd基本上就是一个管道,所以它返回给你一个文件描述符。然后在一个无限循环中,你只是从那个文件描述符读取,你读入这个缓冲区,但这个缓冲区是一个特殊的缓冲区,它是struct signalfd_siginfo,它从管道中读取特殊格式的数据,告诉你你收到了哪个信号。所以,不仅仅是检查你得到的字节是否是一个特定的数字,你可以检查这个结构体的字段是否匹配一个特定的信号,并相应地处理它。你可以说,哦,我收到了SIGINT,我收到了SIGQUIT,等等。它只是形式化了这个自管道技巧。我们不会要求你使用这个,所以不要觉得你需要完全理解它,但我希望你们记住,如果将来你们要做任何复杂的信号处理,这是你们技巧袋中的一个技巧。

我稍后会谈谈这与Rust的关系。这应该会让你觉得奇怪,因为以前在你的主函数中,你可以有一个循环在做一些有用的事情,比如你可以计算一些值,你可以提示用户输入。但通常在你的主代码体中,你希望有灵活性,能够做一些有用的事情,同时如果需要,能够异步处理信号。所以,在你的主代码体中,你可以做一些有成效的事情,如果SIGINTSIGCHLD到来,你可以暂停你正在做的事情,去处理信号并做出响应,然后回来恢复你的有成效的工作。但似乎使用自管道技巧或设置标志之类的方法,似乎消除了并发的可能性。在你的主代码内部,你要么在做工作,这意味着你不会从管道读取;要么你可以从管道读取以等待信号。但你不能同时做这两件事,因为这两件事都涉及使用CPU做某事,你不能同时做两件事。这对大家清楚吗,大拇指向上还是向下?所以这应该令人沮丧。因为信号处理给了我们一个工具,而这感觉像是把这个工具拿走了。

那么我们如何解决这个问题呢?通常有两种方法来解决这个问题。第一种方法是使用线程。请注意,线程仍然可能有并发问题,但当你使用线程时,我们有更多的工具来推理和控制这些并发问题,我稍后会给你一个具体的例子。所以,当你编写多线程代码时,你有可用的工具,比如锁、信号量、条件变量。如果你现在正在上110,我们还没有讨论所有这些工具,但你会在接下来的一周内看到它们。而如果你使用信号处理程序,你真的没有太多可用的工具。当你进行信号处理时,你唯一的工具是阻塞信号,你试图阻止这个其他异步代码体运行,但你没有工具来优雅地控制两件事同时运行。

解决这个问题的另一个选择是使用一个叫做非阻塞I/O的工具,这也非常常见,是你应该了解的东西,我们将在第八周讨论这个。

我承诺过我会解释这与Rust的关系以及Rust中是如何进行信号处理的。信号处理并没有内置到Rust核心语言中。我认为Rust设计者意识到信号处理是一件混乱的事情,很容易出错。Rust有很多问题要解决,主要是内存安全,他们说让我们推迟这个问题,我们暂时不解决它。如果人们需要信号处理,我们将委托给外部crate,但我们不会将其作为核心语言的一部分提供。所以,如果你想使用信号处理,你必须使用Rust库,其中有几个。

Rust有一个叫做ctrlc的crate,它是一个库,允许你在收到Ctrl+C时运行一个函数,每当收到SIGINT时,这似乎是相当合理的功能。这实际上是如何工作的呢?首先,它创建一个自管道。然后它安装一个信号处理程序,当收到SIGINT时写入管道。然后它产生一个线程。那个线程非常简单,它所做的就是在一个无限循环中,尝试从管道读取一个字节,记住如果没有东西在管道里,它会阻塞等待。然后当它从管道中得到东西时,它只是调用你注册的信号处理函数,也就是你希望它被调用的函数。

所以,这实际上非常简单,这个crate非常容易使用,并且它的好处是Rust已经有非常好的控制机制。我们下周会详细讨论这个,但Rust已经有非常好的控制机制来确保你在使用线程时不会出现竞态条件。所以Rust就像是,好吧,你甚至不需要理解线程就能理解这个,因为你已经理解了借用检查规则。借用检查规则强制执行了什么?没有两个地方的代码可以在至少一个地方修改变量的同时使用它。而这正是导致竞态条件的原因:有人在修改数据,而其他人试图使用它。Rust借用检查器已经防止了这种情况。所以,在这种设置下,你不会遇到并发问题,你的代码中不会有数据竞争。

你可能会问,好吧,但这仍然是并发,对吧?我们有线程与信号处理程序。使用线程与使用信号处理程序有什么不同?看起来你仍然在两个地方同时调用printf,难道你仍然会有问题吗?为了让这一点更具体,让我们解释一下从信号处理程序调用printf有什么问题。你从主代码体调用printf,它锁定了标准输出的文件描述符。然后信号处理程序被调用,这是在printf运行的过程中,来自信号处理程序的printf试图锁定那个文件描述符。但它看到那个位已经被设置为1,表示一个进程正在打印中。所以它等待,信号处理程序无法继续,直到主代码说,好了,我打印完了。但主代码无法完成打印,因为信号处理程序正在运行。所以我们在这里得到了一个典型的死锁。

这与从线程打印不同,因为线程的调度方式不同。所以printf从主线程调用,然后信号处理函数被调用,所以它写入自管道,这导致另一个线程唤醒,那个线程现在调用printf。当这个线程,即信号处理线程,看到已经有进程在打印时,它不能打印。所以它被阻塞。因为它被阻塞了,这意味着操作系统调度器可以自由地调度一个不同的线程。所以它调度主线程,调度主线程的printf,现在主线程去完成打印,然后信号处理线程就可以自由地进行它的打印了。这清楚了吗?为了让这一点更清楚,哦,如果你处理像内存分配这样的事情,我提到这也是一个危险,这里的情况是一样的,它们在那里也有内置的保护措施。我可以快速插一句,我昨晚实际上遇到了同样的问题,我试图在中断被禁用时malloc一些东西,结果发生了不好的事情,我意识到我不应该那样做。

是的,所以不要在信号处理程序内部调用malloc。为了让这一点更清楚,在线程和信号处理程序中,你仍然有并发问题,认识到这一点非常重要。但由于调度方式完全不同,结果往往非常不同。所以,对于线程,你有多个优先级相等的执行线程,在处理器上不断切换。你可以使用锁来保护数据,我们在CS110中还没有讨论锁,但我们会在一周内讨论。对于信号处理程序,处理程序完全抢占所有其他代码。所以,如果你有任何东西在运行,不管是什么,放下你正在做的事情,跳转到信号处理程序,并且信号处理程序将一直运行直到完成。正因为如此,你不能使用锁,不能使用任何其他同步原语。你唯一能做的就是禁用信号处理。事实上,你不应该在信号处理程序中做任何导致阻塞的事情。你不应该在信号处理程序中调用read,不应该在信号处理程序中调用waitpid(不带WNOHANG),不应该在信号处理程序中调用sleep。为什么这很重要?为什么你应该避免在信号处理程序中阻塞?有人知道吗?

这与被多次调用关系不大。我认为在CS110中可能有一个例子,他们在信号处理程序中调用了waitpid(不带WNOHANG),结果发生了什么?是的,有点,因为信号处理程序在完成之前会占用CPU。这意味着你不能运行任何其他代码,比如如果你在main函数中有更多代码应该继续做事情。信号处理程序应该是一个快速的中断,你正在做某事,某事发生,你快速跳过去,响应它,然后跳回你之前做的事情。但如果你在信号处理程序中阻塞,那不会发生,你会跳转到信号处理程序,并且你会一直待在信号处理程序中直到那个函数退出。这是有问题的,因为如果你需要回到你正在做的事情,你不会回去,因为你被阻塞了,你卡在信号处理程序里了。这就是为什么你不能使用锁,因为它会导致死锁,就像我们在printf中看到的那样,你做了获取锁的事情,跳转到信号处理程序,尝试获取锁,然后你就死锁了。

正因为如此,这是整个幻灯片上最重要的一点:信号处理程序与库代码配合得非常差。如果你自己编写所有代码,你可以成功地编写使用信号处理的代码。但如果你尝试将库与你的代码一起使用,一切都会崩溃。我认为printf是一个库,它是GlibC的一部分。库不知道你安装了哪些信号处理程序,也不知道你的信号处理程序做什么。所以在你的代码中,你有一个全局变量,你会说,好吧,我在这里接触这个全局变量,我在信号处理程序中接触它。当我在下面接触它时,我会禁用信号处理,这样我就不会出现它在两个地方同时被访问的竞态条件。库也使用全局变量,但它们不知道你的信号处理程序。所以它们不知道它们应该禁用你的信号处理程序以避免竞态条件。即使它们知道,如果库开始随意禁用你的信号处理程序,那也会很糟糕,因为你会有非常难以调试的任意行为。

所以库不能禁用信号处理来保护自己免受并发问题的影响。如果你将库与信号处理程序一起使用,你最终会遇到这样的问题。在你的信号处理程序中,你应该只接触你可以控制的代码,或者调用那些被明确标记为可以从信号处理程序中安全调用的库函数。因为库不知道你的信号处理程序,它们不知道如果你的信号处理程序在库代码中间被调用会发生什么坏事。这对大家都清楚吗?


总结与最佳实践

好了,长话短说。尽量避免信号处理。如果你做的事情非常简单,在信号处理程序内部做是可以的。但任何复杂的事情都应该移到信号处理程序之外,你可以使用自管道技巧来处理,或者只是使用库,这就是你在Rust中要做的。

我在信号处理上花了太多时间,但在剩下的10分钟里,我将讨论Google Chrome。如果你对这个感兴趣,想看到我们没有讲到的更多材料,你应该能够自己查看这些幻灯片。


Google Chrome案例研究:多进程架构

进程是相当隔离的。我先谈谈进程与线程。进程非常隔离。你唯一能通信的方式是通过管道相互发送数据,或者通过向对方发送信号。而信号并没有携带太多数据,我有时把这比作向另一个进程扔水果。你向另一个进程扔一个草莓,另一个进程说,哦,我得到了一个草莓。我不知道它从哪里来,不知道为什么,不知道有什么数据与之关联。信号不携带任何关联数据。就像,哦,我得到了那种水果,也许它们知道如何处理特定种类的水果,但除了信号本身之外,没有传递任何消息。

线程则不同。线程也是多个独立的执行线程,类似于进程。但它们共享内存,共享文件描述符表,还共享一些其他资源。所以它们存在于进程内部,它们有自己的栈来支持自己的执行线程,有自己的寄存器来支持自己的执行线程,但除此之外它们共享内存。实际上,在底层发生的是,你仍然有类似于进程的结构体,但有引用表明,如果你想在这个线程中使用内存,你应该看这里,你应该使用这个进程的虚拟地址空间。

考虑到这一点,在设计浏览器时,哪些事情会是重要的?我本来想让我们讨论这个,但为了节省时间,这些是我想到的。你希望浏览器速度快。你希望它不要使用太多内存,如果使用太多内存,你的电脑可能会变慢,在低端机器上无法很好地工作,等等。你希望它的CPU使用效率高,如果使用大量CPU,尤其是在笔记本电脑上,这将非常糟糕,因为它会耗尽你的电池。你需要能够实际构建它,如果你有一个浏览器的好主意但无法实现,那也没人在乎。最后,它需要安全。如果你希望用户能够浏览一些可疑的网站,比如MP3种子网站,一些仿冒的Spotify,同时也能够访问他们的银行账户,并且不让来自一个网站的恶意代码危害另一个网站的重要信息。

那么线程与多进程在这方面如何体现呢?我们来谈谈速度。多线程还是多进程?有人对速度有倾向吗?为什么?没错。是的,所以,哎呀,走错方向了。当你在进程之间切换时,你必须更改正在使用的虚拟地址空间。这实际上有点昂贵,你必须清除处理器的缓存,然后安装一些新的虚拟地址映射,这样做并不便宜。相比之下,线程使用相同的虚拟地址空间,所以在同一进程的不同线程之间切换比在不同进程之间切换更容易。所以对于速度,我可能会说它们可能非常相似,但多线程略有优势。那么内存使用呢?为什么?完全正确。它们共享内存。而如果你创建多个进程,每个进程都必须有自己的内存。这比那更微妙一些,但这是基本思想。确实,如果你fork一堆进程而不是产生一堆线程,你会为进程使用更多内存。那么CPU使用呢?是的,和前两个原因相同,当你创建进程时,你不必复制那么多数据,而且当你在进程之间切换时,上下文切换的开销更小。所以优势在多线程。那么开发便利性呢?比如能够实际构建浏览器。

多线程有巨大优势。对于任何开始做作业3的人来说,你知道这一点,如果你在进程之间传递数字都有这么多困难,想象一下构建一个必须在进程之间传递许多不同类型信息的浏览器有多困难。绝对是多线程的优势。那么安全性和稳定性呢?完全正确。这是这里唯一一个优势绝对属于多进程的要点,特别是因为它们不共享内存。如果你在这些执行线程中有一个错误,并且你使用多线程,那个错误最终破坏了某处的内存,那么你现在就破坏了所有线程的内存。但是,如果你在多进程场景中有一个错误,并且你在一个进程中破坏了内存,你只破坏了那个进程的内存,操作系统会阻止你破坏不同进程的内存。正因为如此,这意味着,当然,你可能有一个导致一个进程崩溃的错误,但至少你所有其他进程仍在运行。你没有把所有鸡蛋放在一个篮子里。

我认为,这是Chrome最终决定构建其多进程架构的唯一原因。对吧,2006年之前的所有浏览器都是单进程多线程应用程序,因为这四个原因。这些原因给出了相当明显的优势,特别是开发便利性,有利于构建多线程浏览器。但Chrome说,你知道吗,我们认为,我直接给你们看引述。哦,天哪,这很有趣。我本来想说明浏览器是如此复杂,它们给你存储API,你可以有并发API,你可以与硬件通信。你可以将MIDI键盘插入计算机并通过Google Chrome访问它,你可以运行汇编代码,你甚至可以运行Windows 95。这太好了,不能不展示。所以这是在用C编写并编译为WebAssembly的模拟器中运行的Windows 95,你实际上可以,这行得通。这是原始的Windows 95代码,这不是某人创建的模拟或假版本,这实际上是原始的磁盘映像,你能做到这一点真是太荒谬了,这些东西极其复杂。


正因为这种复杂性,使得正确实现变得非常困难。Chrome的设计师说,如果我能让它转到下一张幻灯片,但我不能。

为什么它让我这么做?

他们说,几乎不可能构建一个永不崩溃、永不挂起的完美浏览器。我们总是会有漏洞,而只需要一个浏览器插件,比如一个标签页,就能拖垮整个浏览器和当前运行的所有标签页。现代操作系统之所以健壮,是因为它们具有隔离性,因为它们划分资源并相互隔离进程。所以,如果你有一个应用程序崩溃,它不会拖垮整个计算机,我们也需要浏览器有这样的东西。

在另一页上,他们给出了很多理由,应该会让你想起我们在这门课第一讲中讨论的内容。他们说,坚定的攻击者总是能够找到方法来入侵一个进程,突破一个进程。过去有很多可被利用的漏洞,尽管我们尽了最大努力,我们花了多年的投资试图教人们如何编写更好的代码。模糊测试是一种方法,你只是反复向应用程序抛出畸形数据,直到找到使其崩溃的东西。漏洞奖励计划是我们的漏洞赏金计划,我们付钱让人们尝试入侵我们的浏览器,尝试找出问题。尽管如此,我们仍然发现这么多问题,我们甚至不确定我们是否找到了所有问题。比如这些数字只是我们知道的漏洞,但我们也知道黑市上有很多漏洞在出售。

而且漏洞经常是可被利用的。我们在第一讲中讨论过,你如何有一个单字节的缓冲区溢出,然后它变成了一个漏洞利用。我们在这门课中还没有讨论过这个,但有技术可以防止缓冲区溢出造成的损害,但它们并不总是有效。所以他们基本上说,你知道吗,我们已经把我们知道的的一切都投入到了这个问题上,但我们仍然有问题。我们真的需要一个更好的隔离模型,这样如果我们一个进程中有漏洞,它不会影响浏览器中运行的其他标签页或浏览器本身。

顺便说一下,有人知道Rust是从哪里来的吗?Rust来自Mozilla,它开发Firefox。Firefox的方法一直有点不同。他们说,你知道吗,与其试图用进程来隔离这些问题,我们能不能尝试修复这些问题?Chrome基本上说我们无法修复这些问题。我们非常努力地尝试过,但我们无法修复所有问题。Mozilla说,让我们尝试创建一种方法,让我们产生更少的问题。他们为Firefox构建了Rust。这就是Rust存在的原因,因为这是他们试图创建一种语言的尝试,在这种语言中,我们无法引入这些问题。顺便说一下,这两种方法都还没有成功。Chrome仍然有漏洞。Firefox也有漏洞,Rust也有,你在那里也可能遇到问题。它还比较新。

我有点超时了,但非常快速地,这基本上是Chrome如何使用进程的。所以有一个主要的浏览器进程,它渲染所谓的浏览器“Chrome”,即出现在顶部的部分,如标签控件等,它内部有多个线程来管理UI、发出网络请求等等。然后每个标签页都有一个渲染器进程,用于渲染该标签页内的页面。所以,如果你访问某个不受信任的网站,它在一个进程中运行;你在不同的标签页中访问你的银行,它在另一个不同的进程中运行。这里有一篇非常好的文章链接,里面有这些精彩的插图,但实际上也有相当不错的技术概述。其核心是它使用这些叫做管道的东西来进行隔离,然后使用管道来管理进程之间的通信。

所以它通过这些管道发送消息,Chrome不使用信号来处理事件,因为信号携带的数据不够。假设有人在键盘上按下一个键,你想通知这个渲染进程,嘿,有人在键盘上按了一个键,但如果你发送一个信号,你将无法发送任何关联数据,比如哦,是键盘上的字母A被按下了。而且我们讨论了信号处理有这么多问题,所以他们根本不使用信号处理,他们通过这些管道发送消息,说哦嘿,键盘上的字母A被按下了,哦嘿,有人按了Command+W,等等。这基本上就是它的工作原理。

我这里还有几张幻灯片,讨论了Chrome在过去五年中所做的一些工作。比如,这是他们从2006年开始的模型,但自那以后发生了很多事情。特别是,有一个叫做“站点隔离项目”的倡议,花了四年时间,他们在其中使用了更多的多进程。他们说,你知道吗,我们已经使用了多进程,但这还不够好,我们需要更多的隔离。这是一个非常重要的巨大倡议。所以,如果你想了解,可以查看幻灯片。事实证明,在这种设置下,漏洞仍然是可能的,它们仍然发生,但它们通常涉及串联许多漏洞,因为首先你必须突破这个进程,然后你必须进入这个进程,然后你必须进入其他一些进程,攻击者必须找到方法来摆脱这种隔离设置。所以,如果你想了解,这里有一些有趣的链接。然后这只是一点不重要的花絮,但我们上次讨论了如何不调用fork,而我们的方法实际上给Chrome带来了一些问题。所以,如果你感兴趣,可以查看这个链接,看看他们如何处理fork

抱歉超时了,但这就是我今天的所有内容。如果你对其中任何内容有任何疑问,我认为这真的很有趣,我很乐意在Slack上讨论。是的,谢谢大家,我们周二见。


本节课总结

在本节课中,我们一起学习了信号处理中的常见陷阱,包括为什么应避免使用signal函数、信号处理程序中的安全问题(如数据竞争和死锁),以及处理复杂信号逻辑的最佳实践(如使用自管道技巧或专用库)。我们还通过Google Chrome的案例,深入探讨了多进程架构如何权衡速度、内存、开发便利性与安全性,以实现浏览器的稳定与安全。理解这些概念对于构建健壮的系统软件至关重要。

009:多线程入门

在本节课中,我们将学习多线程编程的基础知识。我们将回顾上周关于Google Chrome架构的讨论,了解其为何选择多进程模型,并正式引入Rust中的多线程概念。通过对比C语言中常见的线程错误,我们将看到Rust的所有权模型如何帮助我们在编译期就防止数据竞争。

课程概述与回顾

首先,我们进行一些课程事务的说明。

我们已在Slack上发布了本周的问卷调查,这是本周唯一的练习部分。请在周三晚上前完成,这将帮助我们改进课程。

第一个项目“D调试器”已经发布。这是一个系统级的项目,涉及处理寄存器和原始内存地址。项目使用与trace作业相同的基础,但功能更强大。项目截止日期是两周后的周二,建议在本周日之前完成前四个里程碑。

回顾过去四周,我们学习了很多内容:全新的内存安全管理模型、OptionResult类型、作为继承替代方案的trait、泛型、堆内存分配以及引用计数指针。上周我们还讨论了如何安全地使用forksignalpipe等多进程构造。大家做得非常出色。

展望未来,接下来两周我们将专注于多线程,这是Rust的核心优势之一。之后,我们将讨论异步编程(非阻塞I/O)、网络服务设计,并回顾所学知识如何应用于C++或Go等其他系统编程语言。

Google Chrome架构深入探讨

上一节我们介绍了Chrome的多进程模型,本节中我们来看看这种设计背后的具体安全考量。

现代浏览器极其复杂,实现一个没有漏洞的正确浏览器非常困难。Chrome团队的策略是承认漏洞不可避免,转而致力于将漏洞的影响范围限制在尽可能小的区域内。

他们通过多进程模型来实现这种隔离。每个标签页运行在一个独立的“渲染进程”中,这是权限最低的进程,负责运行JavaScript、解析HTML和CSS。而网络请求、文件系统访问等特权操作则由一个中心的“浏览器进程”处理。

以下是这种设计的核心优势:

  • 标签页间隔离:一个标签页被攻破或崩溃,不会影响其他标签页或整个浏览器。
  • 标签页与主机隔离:被攻破的渲染进程权限有限,无法随意访问文件系统或向网络中的其他机器发送请求。

那么,这些进程之间如何通信呢?它们几乎完全使用管道进行通信。Chrome使用了一个库,通过消息传递模型,将结构体序列化为文本通过管道发送,接收端再反序列化回结构体。这使得高层开发无需关心底层的文件描述符和文本解析。

然而,这种模型最初仍存在不足。例如,一个标签页内可能通过<iframe>嵌入来自不同网站的多个内容(如广告),它们最初共享同一个渲染进程。如果恶意网站通过漏洞控制了该进程,它就能访问同进程中其他<iframe(如银行网站)的内存。

为此,Chrome启动了庞大的“站点隔离”项目,即使在同一标签页内,来自不同站点的内容也运行在独立的进程中。这在2018年因Spectre和Meltdown这类硬件漏洞而变得尤为重要。尽管实施难度极大,但这显著提升了安全性。

尽管如此,沙箱逃逸漏洞每年仍会出现,但通常需要串联多个漏洞才能实现,难度和成本都很高。这说明了安全是一个需要层层设防的持续过程。

多线程编程简介

上一节我们讨论了Chrome如何利用进程实现隔离,本节中我们来看看另一种实现并发的方式:多线程

为什么使用多线程?

  • 实现并发:允许长时间运行的操作不阻塞程序的其他部分。
  • 性能与轻量:线程比进程更轻量,创建和切换开销更小,共享内存使得线程间通信比进程间通信(IPC)更容易。

为什么多线程危险?

  • 数据竞争:多个线程访问同一数据,且至少有一个进行写操作,可能导致数据损坏或读取到不一致的值。
  • 死锁:多个线程相互等待对方持有的资源,导致所有线程都无法继续执行。

Rust的所有权模型在解决数据竞争方面表现出色。有趣的是,Rust最初将内存安全和并发安全视为两个独立目标,但后来发现所有权和借用规则恰好同时解决了这两个问题。Rust编译器会阻止可能引发数据竞争的代码编译。

一个悲剧性的案例:Therac-25

Therac-25是一款放射治疗机,在1980年代导致多起严重辐射伤害甚至死亡事故。调查发现,根本原因之一是一个竞态条件

该机器有两种模式:低功率的“直接电子束”模式和高功率的“X射线”模式(需放置一个金属扩散器)。操作界面线程和控制线程在快速切换模式时未能正确同步,导致可能出现扩散器未就位时却发射高功率电子束的情况。这个难以复现的竞态条件,在操作员熟练到能在8秒内完成特定操作序列时被触发。

这个案例表明,竞态条件在医疗设备、自动驾驶、金融系统等关键领域可能造成灾难性后果。

Rust中的线程基础

以下是如何在Rust中创建线程的基本示例:

use std::thread;
use std::time::Duration;
use rand::Rng;

fn main() {
    let mut handles = vec![];

    for _ in 0..20 {
        let handle = thread::spawn(|| {
            let mut rng = rand::thread_rng();
            let sleep_time = rng.gen_range(100..500);
            thread::sleep(Duration::from_millis(sleep_time));
            println!("Thread finished running!");
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().expect("Thread panicked!");
    }
}

代码解析:

  • thread::spawn 用于创建新线程,它接受一个闭包(|| {...}),该闭包内的代码将在新线程中运行。
  • join() 方法等待线程结束。它返回一个Result,因为线程可能会恐慌(panic)。默认情况下,一个线程的恐慌不会终止整个程序。

Rust如何防止常见错误

考虑一个C语言中常见的错误模式(“自我介绍”例子):

// C 代码 (存在bug)
char *names[] = {"Frank", "John", "Laura", "Marco", "Julie", "Patty"};
for (int i = 0; i < 6; i++) {
    pthread_create(&threads[i], NULL, intro, &i); // 传递局部变量 i 的地址
}
// 线程函数 intro 会打印 names[i]

问题在于,我们向线程传递了局部变量 i 的地址。for循环迭代很快,在线程开始读取 i 的值之前,i 可能已经自增或循环已经结束,导致线程读取到错误的下标,可能打印出错误的名字、重复的名字,甚至越界访问。

如果我们尝试在Rust中直接翻译这段代码:

let names = ["Frank", "John", "Laura", "Marco", "Julie", "Patty"];
let mut handles = vec![];

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/273e4e7ec71e372474f26e7df8760fdc_11.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs110l-rs-sec-prog/img/273e4e7ec71e372474f26e7df8760fdc_13.png)

for i in 0..6 {
    let handle = thread::spawn(|| {
        println!("Hello from {}", names[i]); // 错误!闭包捕获了 `i`
    });
    handles.push(handle);
}

Rust编译器会拒绝这段代码!它会指出:闭包捕获了引用 &i,但这个引用可能比变量 i 存活得更久(因为线程可能比当前循环迭代活得更长)。这正是C代码中那个bug的本质。Rust在编译期就强制我们解决这个问题,彻底避免了数据竞争。

解决方案通常是使用 move 关键字获取所有权,或者传递数据的副本。我们将在后续课程中详细探讨。

总结与展望

本节课中我们一起学习了多线程编程的动机与风险,并通过Therac-25的案例看到了竞态条件的严重性。我们介绍了Rust中创建线程的基本方法,并看到了Rust的所有权模型如何通过编译期检查,有效防止了C语言中常见的一类数据竞争错误。

然而,Rust并非万能。它主要解决了数据竞争的问题,但对于更高层面的逻辑竞态条件(如Therac-25中的同步问题)以及死锁,程序员仍需谨慎处理。安全是一个综合工程,需要结合语言安全特性(如Rust)、系统架构(如Chrome的沙箱)和良好的编程实践。

下节课我们将更深入地探讨Rust中线程间的数据共享与同步机制。

010:共享内存

在本节课中,我们将要学习Rust中的多线程编程与共享数据。我们将探讨如何安全地在多个线程之间共享和修改数据,理解Rust如何通过其类型系统防止常见的并发错误,并学习使用ArcMutex等工具来构建线程安全的程序。


从CS110的例子说起

上一节我们介绍了多线程的基本概念。本节中我们来看看一个来自CS110的经典例子——“外向者演示”。这个程序创建了多个线程,每个线程打印一个名字。然而,程序运行到最后,Jerry的名字会反复出现,这暴露了什么问题?

问题的核心在于,多个线程可能同时访问和修改同一个共享变量(比如一个计数器或索引),导致数据竞争和不一致的状态。在C/C++中,我们需要手动使用锁(如互斥锁)来保护共享数据。在Rust中,编译器会帮助我们识别这类问题。


尝试在Rust中创建线程

让我们尝试在Rust中编写一个类似的程序。我们创建一个线程向量,每个线程打印一个名字。

use std::thread;

fn main() {
    let names = vec!["Frank", "Patty", "Julie", "Marco", "Lauren", "John"];
    let mut threads = vec![];

    for i in 0..names.len() {
        let handle = thread::spawn(|| {
            println!("{}", names[i]);
        });
        threads.push(handle);
    }

    for handle in threads {
        handle.join().unwrap();
    }
}

当我们尝试编译这段代码时,Rust编译器会报错。它指出闭包可能比当前函数存活得更久,但它借用了i,而i归当前函数所有。这本质上是生命周期问题:我们不知道线程会存活多久,如果主线程先结束,i的引用就会变成悬垂指针。

编译器甚至给出了建议:使用move关键字。move会强制闭包获取其捕获变量的所有权。对于实现了Copy特性的类型(如整数i),这实际上会复制一份值给闭包。

let handle = thread::spawn(move || {
    println!("{}", names[i]);
});

修改后,程序可以正常运行,并且由于线程调度的不确定性,每次运行名字出现的顺序可能不同。


共享数据的挑战:售票代理示例

上一节我们看到了如何将值移动到线程中。本节中我们来看看当多个线程需要读写同一个数据时会发生什么。考虑另一个CS110经典示例——售票代理。

多个售票代理共享一个剩余票数计数器。每个代理在票数大于0时,处理一个呼叫并减少票数。在C语言中,递减操作remaining_tickets--不是原子的,它可能被分解为多条机器指令。如果两个线程的指令交错执行,就可能导致数据竞争,最终售出的票数可能超过实际票数。

更隐蔽的是,即使检查remaining_tickets > 0的操作,也需要在锁的保护下进行,否则检查后状态可能被其他线程改变。


在Rust中实现共享计数器

让我们尝试在Rust中编写这个有问题的售票程序。

use std::thread;

fn ticket_agent(remaining_tickets: &mut usize) {
    while *remaining_tickets > 0 {
        // 处理呼叫
        *remaining_tickets -= 1;
        // 可能休息一下
    }
}

fn main() {
    let mut remaining_tickets = 250;
    let mut threads = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(|| {
            ticket_agent(&mut remaining_tickets);
        });
        threads.push(handle);
    }

    for handle in threads {
        handle.join().unwrap();
    }
}

编译器会立刻阻止我们。它指出remaining_tickets的引用生命周期可能不够长,并再次建议使用move。但如果我们使用move,每个线程将获得remaining_tickets的一个副本,它们将无法共享状态,这显然不是我们想要的。

我们需要一种方式,让多个线程能够安全地指向并修改堆上的同一块内存。


引入 ArcMutex

为了安全地共享可变数据,Rust标准库提供了两个关键工具:ArcMutex

  • Arc<T> (Atomic Reference Counting): 原子引用计数指针。它允许多个所有者同时拥有对同一堆上数据的引用,并通过原子操作更新引用计数,因此可以安全地在线程间共享。
  • Mutex<T> (Mutual Exclusion): 互斥锁。它包装一个值,并确保一次只有一个线程可以访问该值。要访问数据,线程必须首先通过lock方法获取锁。

为什么需要两者结合?

  • 单独的Mutex无法被多个线程“拥有”。我们需要Arc来创建指向同一个Mutex的多个“句柄”。
  • 单独的Rc<T>(非原子引用计数)不能用于多线程,因为其引用计数的更新不是线程安全的。

以下是修正后的售票代理程序:

use std::sync::{Arc, Mutex};
use std::thread;

fn ticket_agent(remaining_tickets_ref: Arc<Mutex<usize>>) {
    loop {
        // 获取锁,guard是一个智能指针,当其离开作用域时会自动释放锁
        let mut remaining_tickets = remaining_tickets_ref.lock().unwrap();

        if *remaining_tickets == 0 {
            break;
        }

        // 处理呼叫
        *remaining_tickets -= 1;

        // 释放锁(remaining_tickets离开作用域)
    }
    // 代理可能休息一下(此时不持有锁)
}

fn main() {
    let remaining_tickets = Arc::new(Mutex::new(250));
    let mut threads = vec![];

    for _ in 0..10 {
        // 克隆Arc,增加引用计数,获得一个新的指向同一Mutex的指针
        let remaining_tickets_ref = remaining_tickets.clone();
        let handle = thread::spawn(move || {
            ticket_agent(remaining_tickets_ref);
        });
        threads.push(handle);
    }

    for handle in threads {
        handle.join().unwrap();
    }
}

关键点解析:

  1. Arc::new(Mutex::new(250)): 在堆上创建一个受互斥锁保护的整数。
  2. remaining_tickets.clone(): 克隆Arc,增加引用计数,返回一个新的指向同一Mutex的智能指针。这不会复制内部数据。
  3. lock().unwrap(): 尝试获取锁。如果成功,返回一个MutexGuard。这个守卫实现了DerefDerefMut,因此我们可以像使用&mut usize一样使用它。当守卫离开作用域时,锁会自动释放。这种模式被称为“锁守卫”(Lock Guard),能有效防止忘记解锁。

线程安全的标记:SendSync

当编译器之前抱怨Rc<RefCell<T>>不能安全共享时,它提到了SendSync这两个特性。

  • Send: 标记一个类型的所有权可以安全地转移到另一个线程。
  • Sync: 标记一个类型的引用可以安全地共享给多个线程(即&TSend)。

它们是标记特性,没有方法,仅用于在类型系统中表达并发安全性。Arc<T>Mutex<T>都实现了SendSync(当T满足一定条件时),这意味着它们可以安全地用于多线程上下文。而Rc<T>RefCell<T>没有实现这些特性,因此编译器禁止我们在线程间传递它们。


性能对比:一个I/O密集型示例

多线程的一个主要优势是处理I/O密集型任务,例如网络请求。考虑一个“维基百科链接探索者”程序:从一个起始页面开始,找到页面上链接中最长的文章标题。

顺序版本需要依次下载每个链接页面并检查其长度,速度很慢(例如,可能花费近3分钟)。

多线程版本可以同时发起多个网络请求,极大地重叠了I/O等待时间。使用类似Arc<Mutex<...>>的结构来安全地更新“找到的最长文章”这一共享状态,可以将执行时间缩短到数秒(例如9秒),性能提升显著。


总结与展望

本节课中我们一起学习了Rust中基于共享内存的多线程编程。

  1. 我们回顾了数据竞争的问题,并看到Rust编译器如何通过生命周期和所有权规则在编译期阻止不安全的共享。
  2. 我们学习了使用Arc<Mutex<T>>组合来安全地在线程间共享可变状态。Arc负责所有权的共享,Mutex负责访问的互斥。
  3. 我们了解了SendSync特性,它们是Rust类型系统用于保证线程安全的核心抽象。
  4. 最后,我们看到了多线程如何显著加速I/O密集型任务。

需要注意的是,Rust能防止数据竞争,但无法防止死锁(例如,以不同顺序获取多个锁)。这仍然需要程序员仔细设计。

在下节课中,我们将探讨更多的同步原语(如信号量、条件变量),并在后续课程中了解超越共享内存模型的并发编程方法(如消息传递)。

011:同步

在本节课中,我们将要学习Rust中关于多线程的同步。我们将从回顾上节课末尾的内容开始,然后过渡到一些新的、有趣的同步原语,这些内容Ryan将在周四继续讲解。

回顾:链接探索器

上一节我们介绍了链接探索器的概念。其核心思想是,你可以在维基百科页面之间跳转,尝试从某个词条(例如“disentre”)开始,最终到达另一个词条(例如“revolver”)。但为了简化,我们这次的目标是:扫描整个维基百科页面,尝试找到HTML体积最大的链接。

我们将首先尝试顺序执行这个任务。让我切换到终端演示一下。

我将运行顺序版本的链接探索器。运行 cargo run 后,程序会顺序遍历所有链接。链接数量很多,顺序执行大约需要三分钟,这并不高效。

接下来,我们看看多线程版本。运行多线程版本后,程序能相当快地处理完所有链接。虽然速度并非极快,但相比顺序版本有了显著提升。有趣的是,根据我们上次的观察,“人工智能”似乎是计算机科学中最重要的主题。

代码解析:使用 Mutex 保护共享数据

现在,让我们深入看一下代码。我打开 main.rs 文件。

我们使用了一个名为 reqwest 的库来发起HTTP请求,下载网页的HTML内容。代码的核心部分在于使用 Mutex 来保护共享数据。

以下是关键部分:

struct Article {
    url: String,
    len: usize,
}

我们定义了一个 Article 结构体来存储URL和长度。你可能会问,为什么需要将这两个字段放在一个结构体中?这与 Mutex 的构造方式有关。

在Rust中,Mutex 的构造函数接收一个需要保护的数据。如果我们想用同一个互斥锁保护两个独立的变量,就必须将它们打包到同一个结构体中。这与C++不同,在C++中,你可以声明两个变量和一个独立的互斥锁变量,在使用变量前后手动加锁和解锁。

从设计角度看,这实际上很好。这被称为“监视器”风格的并发,即将相关的数据分组在一起。Rust的类型系统强制我们这样做,因为 Mutex::new 只接收一个数据参数。

Mutex 被这样构造和使用:

let longest_article = Arc::new(Mutex::new(Article { url: String::new(), len: 0 }));

当我们锁定互斥锁时(例如 longest_article.lock().unwrap()),它会返回一个指向内部数据的特殊引用(一个 MutexGuard)。这个引用确保我们拥有对共享数据的独占访问权。当这个引用离开作用域时,锁会自动释放。

线程批处理与资源限制

在代码中,我们采用了批处理线程的策略:生成一批线程,等待它们全部完成(join),然后再生成下一批。你可能会想,为什么不一次性生成所有线程?

让我们看看如果将批处理大小设置得过大(例如100)会发生什么。运行程序后,我们遇到了错误:“too many open files”。这是因为每个网络连接都会创建一个文件描述符。如果同时建立过多连接,就会耗尽系统的文件描述符限制,导致程序崩溃。

此外,我们也不希望用过多请求压垮服务器。批处理策略的问题在于不够灵活和动态。更好的方法是使用线程池,这将在CS110L的作业6中实现。

引入信号量进行节流

上一节我们讨论了批处理的局限性,本节我们来看看如何使用信号量来更有效地节流。

一种更有效的方法是使用信号量作为“许可条”来限制同时进行的请求数量。你需要先获取许可,才能开始下载文章。熟悉信号量这种用法的同学应该知道,这在CS110的课程和作业5中都有涉及。

为了在Rust中实现信号量,我们首先需要理解条件变量。

条件变量快速回顾

条件变量与我们在多进程中见过的 sigsuspend 类似。其核心思想是:

  1. 获取一个锁(例如互斥锁)。
  2. 检查某个条件是否成立。
  3. 如果条件不成立,则“等待”。在等待期间,线程会被阻塞,不消耗CPU资源,相当于被移到了阻塞队列。
  4. 当其他线程通知条件变量时,等待的线程被唤醒。
  5. 线程重新获取锁。
  6. 再次检查条件,如果成立则继续执行。
  7. 锁守卫离开作用域时自动释放锁。

在C++中,信号量的实现可能如下所示(伪代码):

void wait() {
    lock_guard<mutex> lg(m);
    while (value == 0) {
        cv.wait(m); // 释放m并等待,被唤醒后重新获取m
    }
    value--;
}
void signal() {
    lock_guard<mutex> lg(m);
    value++;
    if (value == 1) cv.notify_all();
}

关于 notify_allnotify_one:使用 notify_all 更安全。虽然它可能唤醒多于所需的线程(这些线程醒来后检查条件,不满足会再次睡眠),但功能上是正确的。而错误地使用 notify_one 可能导致死锁,例如唤醒了无法继续执行的线程,而能继续执行的线程却仍在睡眠。

Rust中的条件变量

在Rust中,习惯上将条件变量与互斥锁配对使用,通常放在一个元组或结构体中。这是因为条件变量与特定的互斥锁(以及该锁保护的数据)相关联。你不应该让一个条件变量负责多个互斥锁,那会使代码难以理解并可能导致死锁。

MutexCondvar 的接口能帮助你编写更安全、更清晰的代码。让我们看看如何实现一个比简单信号量更强大的工具。

构建 SemaphorePlusPlus:一个通道

我们想构建一个 SemaphorePlusPlus(简称 S++),它不仅仅是递增递减计数器,还能发送和接收消息。可以把它想象成一个线程安全的队列(通道)。

主线程可以克隆 S++ 并将其分发给多个工作线程。工作线程完成任务后,可以调用 s.send(result) 发送结果或状态消息。主线程则调用 s.receive() 来接收这些消息。如果队列为空,receive() 会阻塞,直到有消息到达。

这类似于将数据存储(队列)和同步机制(信号量/条件变量)结合在了一起,使用起来更简单,无需直接操作共享内存和条件变量。

实现 SemaphorePlusPlus

让我们开始实现。首先看结构体定义和构造函数:

struct SemaphorePlusPlus<T> {
    qc: Arc<(Mutex<VecDeque<T>>, Condvar)>,
}
impl<T> SemaphorePlusPlus<T> {
    fn new() -> Self {
        let pair = (Mutex::new(VecDeque::new()), Condvar::new());
        SemaphorePlusPlus { qc: Arc::new(pair) }
    }
}

它包含一个 Arc,里面是一个元组,元组里有一个保护 VecDeque(双端队列)的 Mutex 和一个 Condvar

我们需要为它实现 Clone。由于唯一字段 qcArc,而 Arc 实现了 Clone,我们可以使用派生宏:

#[derive(Clone)]
struct SemaphorePlusPlus<T> {
    qc: Arc<(Mutex<VecDeque<T>>, Condvar)>,
}

实现 send 方法

send 方法将消息放入队列,并通知等待的消费者。

fn send(&self, message: T) {
    let (q, c) = &*self.qc; // 解引用Arc并解构元组
    let mut queue = q.lock().unwrap(); // 获取锁守卫
    queue.push_back(message); // 入队
    if queue.len() == 1 { // 如果队列从空变为非空
        c.notify_all(); // 通知所有等待者
    }
    // lock守卫离开作用域,自动释放锁
}

注意:我们只在队列长度变为1(即从空变为非空)时才调用 notify_all(),这是一个优化,避免不必要的唤醒。

实现 receive 方法

receive 方法从队列中取出消息,如果队列为空则等待。

fn receive(&self) -> T {
    let (q, c) = &*self.qc;
    // 获取锁,并将锁守卫传入 `wait_while`
    let mut queue = c.wait_while(q.lock().unwrap(), |queue| queue.is_empty()).unwrap();
    // `wait_while` 返回时,我们已重新获得锁守卫,并且队列不为空
    queue.pop_front().unwrap() // 出队并返回
}

Condvar::wait_while 方法接收一个锁守卫和一个谓词闭包。它会释放锁并等待,直到被通知且谓词条件为假(即队列不为空)时,重新获取锁并返回锁守卫。这确保了在检查条件和执行操作(pop_front)期间,锁始终被持有,避免了竞争条件。

这里的关键是,wait_while 消耗(获取所有权)并最终返回锁守卫,这由Rust的所有权系统保证,防止了在等待期间错误地访问数据。

总结

本节课中我们一起学习了Rust中的同步机制。

  1. 我们回顾了使用 Mutex 保护共享数据的方式,并理解了为何需要将相关数据打包到结构体中。
  2. 我们探讨了多线程程序中资源限制(如文件描述符)的问题,以及批处理策略的优缺点。
  3. 我们回顾了条件变量的工作原理,并将其与信号量的节流用途联系起来。
  4. 我们深入研究了Rust中 MutexCondvar 的接口,看到了它们如何通过类型系统强制关联锁与数据,从而编写出更安全的代码。
  5. 最后,我们实现了一个 SemaphorePlusPlus(通道),它结合了队列和条件变量,提供了线程间通信的更高级抽象。在实现中,我们特别关注了 Condvar::wait_while 方法如何安全地管理锁的生命周期,确保同步的正确性。

Rust的同步原语通过其所有权和类型系统,帮助开发者避免了许多在C/C++中常见的并发错误,例如未加锁访问数据、错误关联条件变量与锁等。虽然代码有时看起来紧凑而复杂,但它内在的安全性保障是强大的。

012:通道 🚀

在本节课中,我们将要学习一个名为“通道”的并发原语。我们将探讨其概念、工作原理、与信号量和管道的对比,以及在Rust中的实际应用。通过理解通道,我们可以掌握一种避免数据竞争的并发编程方法。

概述

通道是一种用于线程间通信的机制,它允许线程通过发送和接收消息来交换数据,而不是通过共享内存。这种方法可以避免数据竞争,简化并发编程。

通道的概念与动机

上一节我们介绍了通道的基本概念,本节中我们来看看其背后的动机。

多线程编程允许我们并行执行任务,提高程序性能。然而,共享内存可能导致数据竞争,使得程序难以调试和维护。通道提供了一种替代方案:线程不共享内存,而是通过传递消息进行通信。

Go语言有一句著名的口号:“不要通过共享内存来通信,而要通过通信来共享内存。” 这句话强调了消息传递的重要性。通过通道,线程可以将数据放入消息中发送给其他线程,从而实现数据共享。

通道与信号量的对比

为了理解通道,我们可以将其与信号量进行对比。信号量用于同步,但不携带数据。通道则结合了数据存储和同步功能。

以下是信号量的工作流程:

  1. 线程等待信号量,检查缓冲区是否有数据。
  2. 如果有数据,线程锁定互斥锁,从缓冲区取出数据,然后解锁。
  3. 如果没有数据,线程阻塞,直到其他线程向缓冲区添加数据并发送信号。

通道的工作流程类似,但更简洁:

  1. 线程调用 receive 方法从通道接收数据。如果通道中有数据,直接取出;如果没有,线程阻塞。
  2. 其他线程调用 send 方法向通道发送数据,唤醒等待的线程。

通道内部通常使用队列(如链表)来存储数据,允许多个数据项同时存在。

通道与管道的对比

通道也可以与管道进行对比。管道用于进程间通信,而通道用于线程间通信。

以下是两者的主要区别:

  1. 速度:通道比管道更快,因为管道需要系统调用,而通道在用户空间内操作。
  2. 数据类型:管道传输字节流,而通道可以传输任意类型的数据(如结构体),无需序列化和反序列化。

通道的性能考虑

虽然通道避免了数据竞争,但消息传递可能带来性能开销。如果每次传递数据都需要深拷贝,会导致大量内存复制。实际上,通道通常只进行浅拷贝,共享堆内存中的数据。

例如,传递一个 Vec 时,只复制其结构体(包含指针和长度),而不复制实际数据。这样可以减少开销,但需要注意所有权问题。在Rust中,发送数据到通道会转移所有权,防止后续使用导致数据竞争。

Rust中的通道实现

Rust标准库提供了通道,但当前实现是单消费者(MPSC)的,性能可能不是最优。因此,推荐使用 crossbeam 库中的通道,它支持多生产者多消费者(MPMC)模式,并且性能更高。

以下是使用 crossbeam 通道的基本步骤:

  1. 创建无界通道:let (sender, receiver) = crossbeam_channel::unbounded();
  2. 克隆接收端以供多个线程使用:let receiver_clone = receiver.clone();
  3. 在线程中循环接收数据:while let Ok(num) = receiver_clone.recv() { ... }
  4. 在主线程中发送数据:sender.send(num).expect("发送失败");
  5. 关闭通道:drop(sender);

示例代码:重构Farm程序

我们将使用通道重构Farm程序,使其能够从标准输入动态接收数字并进行因式分解。

以下是核心代码框架:

use crossbeam_channel as channel;
use std::thread;

fn main() {
    let (sender, receiver) = channel::unbounded();
    let num_cpus = num_cpus::get();

    let mut handles = vec![];
    for _ in 0..num_cpus {
        let receiver_clone = receiver.clone();
        let handle = thread::spawn(move || {
            while let Ok(num) = receiver_clone.recv() {
                factor_number(num);
            }
        });
        handles.push(handle);
    }

    // 主线程读取标准输入并发送到通道
    for line in std::io::stdin().lines() {
        let num: u64 = line.unwrap().parse().unwrap();
        sender.send(num).expect("发送失败");
    }

    // 关闭通道,等待工作线程结束
    drop(sender);
    for handle in handles {
        handle.join().unwrap();
    }
}

通道的适用场景与局限性

通道适用于任务分发、结果聚合等场景,简化了并发编程。然而,它也有局限性:

  1. 全局状态共享:如果需要全局缓存或计数器,通道可能不是最佳选择。
  2. 性能要求:在某些高性能场景中,无锁数据结构可能更合适。

总结

本节课中我们一起学习了通道的概念、工作原理及其在Rust中的应用。通道通过消息传递避免数据竞争,简化了并发编程。虽然存在性能开销和局限性,但在许多场景下,通道是一种强大且易用的工具。

013:可扩展性与可用性

在本节课中,我们将学习网络系统设计中的两个核心概念:可扩展性可用性。我们将从网络基础知识开始,了解IP地址、端口和连接的工作原理,然后探讨如何设计能够应对高流量和组件故障的大型系统。课程最后,我们将一起总结所学内容。


网络基础回顾

在深入讨论系统设计之前,我们需要确保大家对一些网络基础知识有共同的理解。如果你对以下细节不完全理解,不必担心,本课程不涉及具体代码实现。但理解这些概念有助于我们后续讨论抽象的系统设计。

IP地址

网络上的每台计算机都有一个IP地址,用于唯一标识它。一个IP地址由4个字节组成,通常表示为四个0到255之间的数字,用点号分隔。例如,192.168.1.230是一个有效的IP地址。

如果你想与网络上的另一台计算机通信,你需要知道它的IP地址。这就像寄信需要收件人的地址一样。

DNS服务器

然而,我们通常不直接记住IP地址(例如,很少有人记得Google的IP地址)。为了解决这个问题,计算机会配置一个DNS服务器的地址。当你想要访问google.com时,你的计算机会询问已知的DNS服务器(例如8.8.8.8):“google.com的IP地址是什么?” DNS服务器会回复相应的IP地址,然后你的计算机就可以直接与Google服务器通信了。

端口号

端口号的概念有时会让初学者感到困惑。一个常见的类比是:将互联网上的每台计算机看作一个公寓楼

  • IP地址就像是公寓楼的街道地址。
  • 端口号就像是公寓楼内的单元号

例如,如果你想访问web.stanford.edu的网页服务(HTTP),你需要:

  1. 通过DNS找到它的IP地址(找到公寓楼)。
  2. 然后“敲开”运行HTTP服务的“公寓门”,即端口80

同样,如果你想通过SSH连接到my.stanford.edu,你需要找到它的IP地址,然后连接到运行SSH服务的端口22

这些端口号是约定俗成的标准。虽然你可以将自己的服务运行在任何端口上,但使用标准端口(如HTTP用80,HTTPS用443,SSH用22)能让其他人更容易找到你的服务。


连接是如何建立的

上一节我们介绍了地址和端口,本节我们来看看两台计算机之间是如何建立连接并通信的。

在计算机网络术语中,主机就是指一台计算机。每台主机上运行着一个或多个进程。

服务器端:绑定端口

如果一台主机上的一个进程(例如PID 1234)想要提供一个网络服务(如Web服务器),它需要执行一个称为绑定到端口的操作。

  1. 选择“公寓”:进程需要选择一个端口号,例如Web服务器选择端口80。这就像在公寓楼里选一个单元住下。
  2. 设置“等候名单”:进程会在“公寓门外”安装一个“等候名单”(在操作系统中,这对应一个特殊的文件描述符,称为监听套接字)。当其他计算机尝试连接这个端口时,它们会“在名单上签名”排队。
  3. 接受连接:服务器进程通过读取这个特殊的文件描述符,可以知道有新的连接请求到来。然后,它使用accept系统调用将客户端从等候名单中“请进公寓”,并创建一个新的、专门用于与该客户端通信的套接字(另一个文件描述符)。

一台主机上的不同进程可以绑定到不同的端口,但同一个端口只能被一个进程绑定。一个进程也可以绑定多个端口(例如,Web服务器同时绑定80和443端口以支持HTTP和HTTPS)。

客户端:发起连接

现在,假设网络另一端的另一台计算机(客户端)想要与这个服务器通信。

  1. 寻找服务器:客户端首先通过DNS找到服务器的IP地址。
  2. 连接与排队:客户端尝试连接到服务器的特定IP地址和端口。如果该端口有服务在监听(即有“等候名单”),客户端会将自己添加到名单中排队。
  3. 建立双向通道:服务器接受连接后,双方都会获得一个新的文件描述符(套接字)。关键点在于:这个网络套接字是双向的。客户端和服务器都可以通过它同时读取和写入数据,这与单向的管道不同。
    • 如果客户端向它的文件描述符写入数据,这些数据会通过网络发送到服务器对应的文件描述符。
    • 如果服务器向其文件描述符写入数据,客户端也能读取到。

这种抽象隐藏了网络底层复杂的传输细节,我们只需要关心对文件描述符的读写操作即可。


系统设计:可扩展性与可用性

理解了基础连接机制后,我们现在可以退一步,从更高的视角思考系统设计。本节我们将聚焦于两个关键属性:可扩展性和可用性。

什么是可扩展性?

可扩展性指的是系统随着需求增长而扩展的能力。

  • 理想情况:系统支持线性或次线性扩展。例如,用户量增加10倍,只需将服务器数量增加10倍(线性)或更少(次线性),这非常高效。
  • 糟糕情况:系统无法扩展。无论投入多少资源,其处理能力都存在上限(例如,最多支持1000个并发用户)。

什么是可用性?

可用性指的是系统保持在线、避免停机的能力。

高可用性极具挑战性。假设单台服务器的可用性是99.99%(每年停机不到1小时)。如果你有一个由1000台这样的服务器组成的系统,并且每台服务器都必须正常工作整个系统才能运行(即没有容错设计),那么系统的整体可用性会急剧下降至约90.48%(每年停机超过一个月)。这说明,在多服务器系统中,容错设计至关重要。

可扩展性和可用性有时会相互冲突。为了使系统更易于扩展,我们可能会增加其复杂性,而这又可能降低其可用性。


从单服务器到负载均衡

上一节我们定义了可扩展性和可用性,本节我们来看看一个简单的单服务器架构,并分析其局限性。

一个简单的网络服务模型是:客户端直接连接到单个服务器的IP地址。服务器可以为多个客户端创建多个连接(例如,使用多线程,每个线程服务一个客户端)。

问题:这种架构可扩展吗?
答案是否定的。单台服务器的能力(CPU、内存、网络带宽、文件描述符数量)存在物理上限。当用户量增长时,你只能通过升级硬件(“纵向扩展”)来应对,但这成本高昂且很快会达到技术极限。此外,单台服务器也构成了单点故障,一旦宕机,整个服务就不可用。

解决方案:我们不应只进行“纵向扩展”(使用更强大的机器),而应进行“横向扩展”(使用更多普通机器)。这是谷歌等公司早期成功的关键理念之一:使用大量普通商用服务器协同工作,而非依赖少数超级服务器。


引入负载均衡器

为了横向扩展,我们需要将流量分发到多台服务器上。但客户端只知道一个IP地址(例如google.com),如何指向多台服务器呢?答案是引入负载均衡器

负载均衡器是一台专门的服务器,它的作用是:

  1. 对外提供一个IP地址供客户端连接。
  2. 内部维护一个后端服务器(或称为计算节点)池。
  3. 当客户端连接到来时,负载均衡器接受连接,然后选择一台后端服务器,并在客户端与该服务器之间转发所有网络消息。

以下是负载均衡器带来的好处:

  • 可扩展性:当流量增加时,我们可以轻松地向池中添加更多后端服务器。负载均衡器会自动将新连接分发到所有可用节点上。现代云服务(如AWS)支持自动伸缩,可以根据负载动态增减服务器。
  • 可用性:负载均衡器可以监控后端服务器的健康状态。如果某台服务器故障,它可以停止向其发送流量,从而避免影响整体服务。客户端对此完全无感知。

此时,系统的架构变为:客户端 -> 负载均衡器 -> 多个后端服务器。后端服务器通常是无状态的(或状态存储在独立的、可扩展的数据库中),这使得它们很容易被复制和替换。


负载均衡器本身的扩展

上一节我们通过负载均衡器解决了后端服务器的扩展问题,但负载均衡器本身也可能成为瓶颈和单点故障。本节我们探讨如何让负载均衡器也具备可扩展性和高可用性。

单个负载均衡器存在两个问题:

  1. 可扩展性瓶颈:即使它只做简单的转发工作,其能处理的并发连接数和网络吞吐量也存在上限。像YouTube这样的服务,其流量绝非单台机器所能承受。
  2. 单点故障:如果这台负载均衡器宕机,整个服务依然会中断。

方案一:DNS轮询

一种方法是配置DNS服务器,使其为一个域名返回多个负载均衡器的IP地址列表,并且每次响应的顺序是随机的。

  • 工作原理:客户端拿到IP列表后,会尝试连接第一个,如果失败则尝试第二个,依此类推。不同的客户端可能拿到不同顺序的列表,从而将流量分散到不同的负载均衡器。
  • 缺点
    • 不够智能:无法根据负载均衡器的实时负载进行调整。
    • DNS缓存:DNS响应会被中间网络缓存,导致一段时间内大量客户端连接同一个首选IP,造成负载不均。
    • 故障切换慢:当一台负载均衡器故障后,客户端需要依次尝试连接失败后才会切换到下一个,增加了连接延迟。

方案二:基于地理位置的DNS和任播

大型服务商(如Google)使用更高级的技术。它们在全球拥有多个数据中心。

  • 基于地理位置的DNS:DNS服务器会根据客户端的来源IP,返回距离最近的数据中心的负载均衡器IP地址。这降低了延迟,并实现了流量在全球范围的分布。
  • 任播:这是一种网络层技术。多个数据中心可以宣告同一个IP地址。互联网中的路由器会根据路由协议(如BGP)的成本(通常与距离相关)选择最优路径,将数据包导向最近的数据中心。如果某个数据中心故障,路由器会自动从路由表中移除该路径,流量会被导向其他宣告相同IP地址的数据中心。客户端完全感知不到这一切换过程。

在实际的数据中心内部,也会采用多台负载均衡器构成集群,并结合故障转移机制,进一步消除单点故障。


混沌工程:主动拥抱失败

在结束关于可用性的讨论前,我们介绍一个由Netflix推广的有趣理念——混沌工程

设计高可用系统时,我们必须假设任何组件都会失败。但问题在于,在复杂的系统中,你很难预测所有可能的失败模式及其连锁反应,直到它真正发生。

混沌工程的核心理念是:在受控环境下主动注入故障,而不是被动等待它在凌晨三点意外发生。Netflix开发了一系列工具:

  • Chaos Monkey:随机终止生产环境中的虚拟机实例。
  • Chaos Kong:模拟整个AWS区域(包含多个数据中心)故障。

这样做的好处是:

  1. 在可控时间内发现问题:在工程师准备好的时候进行故障测试,便于快速定位和修复。
  2. 推动构建更健壮的系统:当团队知道生产环境会随机发生故障时,他们就有更强的动力去设计能够容忍这些故障的系统架构。这样,当真实的硬件故障发生时,它只不过是系统日常处理的“噪音”而已,不会引起服务中断。

混沌工程是一种通过主动制造混乱来提升系统韧性的创造性方法。


总结

本节课我们一起学习了构建大型网络系统所需的核心概念。

  1. 网络基础:我们回顾了IP地址、DNS和端口号的工作原理,以及客户端与服务器之间通过套接字建立双向连接的过程。
  2. 核心目标:我们定义了系统设计的两个关键目标——可扩展性(应对增长的能力)和可用性(保持在线的时间)。
  3. 架构演进:我们从简单的单服务器架构出发,分析了其局限性,并逐步引入了负载均衡器来解决后端服务器的扩展和部分可用性问题。
  4. 高级技术:我们探讨了负载均衡器自身的扩展方案,如DNS轮询和更先进的基于地理位置的DNS任播技术,这些技术被谷歌等大型服务商用于实现全球范围的高性能和高可用。
  5. 设计哲学:最后,我们了解了混沌工程这一通过主动注入故障来提升系统韧性的前沿实践。

理解这些设计模式和思想,对于构建能够安全、可靠地服务数百万用户的现代软件系统至关重要。

014:信息安全

在本节课中,我们将继续讨论网络相关主题,并聚焦于信息安全。

上一节课我们讨论了如何构建一个服务,并确保该服务在用户增多时仍能保持可用。今天,我们将探讨如何保护信息的安全。我们将学习如何防止攻击者侵入系统并窃取敏感信息。

网络系统回顾

在深入信息安全之前,我们先快速回顾一下网络系统的基本概念。

通常,在网络服务中,会有一个服务器监听来自一个或多个客户端的网络连接。服务器会等待客户端连接到它绑定的端口,然后开始与对方计算机通信。到目前为止,我们讨论的方式是,通信双方都会获得一个文件描述符,通过向文件描述符写入和读取字节来发送和接收数据。这是底层通信的基本原理。

但我们不希望停留在字节通信这种底层层面。如果能有更正式、更一致的通信方式会更好。通常,两个服务器会使用某种预定义的语言进行通信,我们称之为协议

一个非常常见的协议是HTTP。例如,当你访问一个网站时,你的浏览器就在使用一种定义明确的语言——HTTP——与服务器通信。它以所有HTTP服务器都能理解的格式发送请求,例如“请加载这个页面”。服务器知道如何解释这个请求,因为双方使用的是相同的语言(协议)。然后,服务器会获取网页内容,并使用预定义的响应格式将响应写回文件描述符。这样,互联网上的两台计算机就知道如何相互通信。

服务器接收请求并发送响应。这个概念对大家来说都清楚了吗?

我们需要防御什么?

现在,让我们思考一下,如果我们的服务器存储了敏感信息,我们需要防御哪些攻击。

换个角度思考可能更有帮助:假设你试图入侵一个网络服务器(可以是HTTP服务器,也可以是任何类型的服务器),并窃取信息。你会怎么做?虽然这里大多数人没有此类经验,但从基本原则出发,你会尝试什么?

一种方法是发送随机请求来探测服务器,看看会发生什么。这涉及到信息发现。你可能不完全清楚服务器的功能或所有可用路径。服务器可能对某些请求的响应方式会暴露信息。

还有其他想法吗?有人提到了拒绝服务攻击。拒绝服务意味着你通过使服务器宕机或使其过于繁忙而无法响应他人,从而剥夺他人使用服务的权利。这与信息安全略有不同,因为你并非提取信息,而是使服务对他人不可用,但这确实是一个需要关注的问题。

你还提到了伪造请求,这与信息安全密切相关。发送伪造请求如何有助于攻击?一个很好的思路是尝试冒充系统中有权限的用户。具体方法取决于系统的设置和通信设计。例如,可能只需找出管理员的用户名,然后发送请求说“我是这个人”。如果服务器不够智能,它可能不会验证你的身份。

回到发送伪造或无效请求的想法。另一种方法是发送无效的HTTP请求。我们提到客户端和服务器应使用预定义的HTTP语言通信。但如果你稍微偏离HTTP,发送一些看起来像HTTP但在某些地方有语法错误的东西呢?如果服务器的HTTP解析代码编写不当,就有可能通过发送格式错误的请求来诱导缓冲区溢出。HTTP解析本质上是字符串解析。如果字符串解析不正确,就可能发生缓冲区溢出。然后,攻击者可以利用这一点,在服务器上获得远程代码执行权限,从而上传并运行任意代码。

我们想出了几种不同的攻击策略,它们的实施难度差异很大。例如,发送随机请求探测服务器比精心设计缓冲区溢出攻击要容易得多,后者实际上非常困难。

保护服务器的三个层次

关于如何保护服务器,我认为可以分为三个步骤或层次。

首先,不要向“礼貌询问”的攻击者提供信息。如果有人敲你的前门问“你的社保号码是多少?”,你很可能不应该告诉他。网络服务也是如此。作为每个人都应达到的基线,我们绝对不应该向礼貌询问的攻击者提供敏感信息。

其次,我们需要确保系统的任何依赖项也不会向礼貌询问的攻击者提供信息。

最后,也是最困难的一步,是确保不向“不礼貌询问”的攻击者提供信息。如果有人发送旨在引发缓冲区溢出的畸形HTTP请求,我们希望确保自己不会因此被攻破。

接下来,我将更详细地讨论每一个步骤。

第一层:防御“礼貌询问”的攻击者

首先,我们来看如何防御那些只是简单“询问”的攻击者。

回顾一下,HTTP请求看起来像这样:你以特定格式发送一些字节。例如,你想获取某个路径,比如访问 http://example.com/secret 时,你的浏览器会以这种格式发送请求。然后服务器用一些响应来回复。

假设这是一个存储菜谱的餐厅服务器,攻击者只是问:“嘿,你的超级秘制酱料是什么?”然后服务器回答:“哦,是味精。”这样,服务器就把存储在那里的秘密给出去了。攻击者甚至不需要做任何复杂的事情,只是“礼貌地”问了一下。

没人会这么傻,对吧?攻击从来不会这么明显,对吧?我们希望如此。然而,Panera Bread 的移动订餐应用实际上就是这样工作的。

攻击者可以发送一个请求到 foundation-api/users/{user_id},其中 user_id 是一个数字。服务器会返回 Panera 拥有的关于该用户ID的所有信息,包括电子邮件地址、电话号码、食物偏好等大量信息。这非常糟糕,尤其是因为这些ID是顺序的。系统上注册的每个用户都会获得一个递增的整数ID。因此,攻击者可以简单地枚举每个数字,下载整个数据库。这非常容易,只需“询问”:“我能获取这个用户的信息吗?”

这个事件是一个很好的案例,展示了如何不处理安全漏洞。处理得非常糟糕。安全研究员在八个月里不断尝试联系他们,他们却称其为骗子,并表示不想与他打交道。研究员并没有索要钱财,只是告知漏洞信息,而他们认为他在撒谎。最终,研究员感到沮丧,向媒体曝光。在他联系媒体后的两小时内,Panera 宣布他们已修复问题,并且只有一万用户受影响。但看看请求中的用户ID数字:7382194,这远大于一万。他们不仅谎报了受影响用户的数量,而且实际上也没有真正修复漏洞。他们只是关闭了这个特定的API端点。

API端点是指服务器上可以请求信息的特定路径前缀。你可以向这个端点传递用户ID 7382194,它会返回指定用户的信息。他们修复了这个API端点,但后来发现,该应用中的每一个其他API端点都以完全相同的方式设计。事实上,包括他们企业餐饮应用在内的其他应用也存在完全相同的设计问题,后者包含大量企业数据。

如果你想了解更多,可以阅读发现此问题的研究员撰写的详细报告。需要说明的是,我并非特意针对 Panera。正如你将在本讲座中看到的,这不是 Panera 独有的问题。这是整个行业普遍存在的问题。这也是我们开设这门课程的部分原因:我们希望你们了解所面临的挑战,以及行业中常见的各类问题。

如何正确处理:认证与授权

那么,我们如何正确处理呢?安全领域有两个原则:认证授权

它们听起来相似,都以A开头,拼写长度和难度也差不多,但含义有细微差别。

  • 认证 指的是“你是谁?”即谁在与系统对话。通常通过提供凭据来建立认证,例如,你告诉服务器:“我是某个用户名,这是我的密码,用于验证我是我。”或者提供双因素认证令牌,或提供只有该用户知道的密钥。
  • 授权 指的是“你被允许做你试图做的事情吗?”一旦你知道谁在与你的服务器对话,你还必须验证他们试图做的事情是否被允许。例如,在客户数据的例子中,授权意味着如果我们知道你是特定用户,我们只会授权你访问自己的数据,你不应该被允许获取系统上其他客户的信息。这是由某种策略建立的,例如,你可以访问自己的电子邮件,但不能访问他人的。

要拥有安全的服务,必须同时建立两者。仅有认证是不够的,没有认证的授权也几乎没有意义,因为你不知道在和谁说话。我将举例说明只有其一而没有其二的后果。

实践中的认证与授权

在实践中,这通常看起来是这样的:你有一个客户端和一个服务器。客户端发出请求,并传递一些认证令牌,例如用户名和密码。服务器可能响应说:“很好,下次你想和我对话时,使用这个随机令牌。”我们使用这些令牌而不是每次传递用户名和密码的原因有几个,我稍后会提到。但主要原因是我们希望尽量减少凭据在网络中传输的次数。同时,也希望尽量减少客户端需要保存凭据的时间。例如,你登录一个网站,输入用户名和密码。浏览器在未来需要发送更多请求,但理想情况下,浏览器不应一直保存你的用户名和密码。你只在登录时输入,登录后浏览器就“忘记”它们,以减少编程错误导致泄露的可能性。此外,令牌可以设置过期时间,这很有用,例如两周后过期。

然后,客户端说:“嘿,给我看看用户 cactus 的电子邮件,这是我的令牌。”当服务器收到这个经过认证的请求(它有令牌)时,服务器首先必须验证认证:验证 ABC123 是否是一个有效令牌,并找出它属于谁。它会在令牌数据库中查找,发现这个令牌是发给 cactus 的,所以正在与 cactus 对话。接着,它必须检查授权:它知道正在与 cactus 对话,必须确保 cactus 有权查看 cactus 的电子邮件。根据合理的授权策略,这应该是允许的。然后服务器会响应:“这是用户 cactus 的电子邮件。”

如果你查看网络应用中的请求和响应,这通常就是它们的工作方式。

大家都明白了吗?对此有任何问题吗?

总结一下这里的认证和授权:认证要求客户端证明其身份(这里提供用户名和密码)。授权要求服务器验证与之对话的人是否有权执行其请求的操作。

我提到过,没有令牌也可以,每次请求都传递用户名和密码是可能的,但我们希望避免必须记住用户名和密码,并且我们可以让令牌在一周后过期,让用户重新登录。如果你听说过 cookies 这个术语,它在网络编程和浏览器领域非常常见,cookies 就是令牌,是同样的东西。

缺少认证或授权的后果

Panera 的例子是完全缺乏认证的例子。那些请求都没有经过认证。你不需要提供任何表明身份的东西。所以那张图中没有认证。

这里有一个不那么严重但三周前刚发生的例子。

上周我们讨论了机器集群。如果你有一个包含数千台机器的集群,管理所有这些机器将非常困难。为了执行系统更新或检查是否过载进行监控等,单独 SSH 到每台机器是不切实际的。Salt 是一个系统管理产品,可以让你监督这些大型机器集群。你可以让集群中的许多节点向主服务器“报到”,发送请求说“嘿,这是我的CPU使用率”或“这是当前运行的进程”等。你也可以让系统管理员联系主服务器说:“嘿,请将所有服务器更新到版本10。”这个主服务器有一个内部队列,它会将该消息添加到队列中,最终将该消息广播给所有服务器:“嘿,请安装版本10。”

需要说明的是,我刚才展示的所有请求都经过了适当的认证和授权,那里没有问题。问题在于,如果你碰巧发送了一个请求,指示调用 _send_pub 函数,并且在请求中说“嘿,在所有服务器上安装比特币矿工并杀死 SSH”,而且你没有提供任何认证或授权,那么 Salt 主服务器会很高兴地将该消息添加到其作业队列中,然后将其发送给所有服务器。这完全是个意外,本不应该发生。_send_pub 是主服务器内部的一个函数,当系统管理员发送“请将服务器更新到版本10”这样的指令时才会被调用。它本应是一个私有函数(这就是为什么它以下划线前缀命名)。但不知何故(我没有看过任何相关代码),他们创建了一个映射,使得该函数可以通过网络请求调用。

一开始有人提到“发送一堆请求看看能找到什么”,有时就会出现这种情况。没人打算让这个函数可以从网络请求调用,但结果却是可以的,而且你可以向这些服务器发送任意消息。

那么发生了什么?三周前,整个集群开始变得无法访问。许多公司突然无法连接,无法 SSH,服务器宕机。许多服务器被植入了比特币矿工和后门,允许攻击者进入并窃取数据(尽管就我们所知,似乎主要是比特币挖矿攻击)。但这造成了巨大的麻烦,因为你甚至无法进入服务器。一旦你重新进入,如何验证攻击者是否仍在机器上?如果他们控制了机器并拥有 root 访问权限,他们可以设置一个假象,看起来他们不在机器上,而实际上他们仍在。

如果你想了解更多关于这次攻击的信息,这里有一些链接。这发生在三周前,并非有意为之,只是有人犯了错误,意外暴露了本不应暴露的网络端点。

缺少授权会怎样?

那么,如果你有认证但没有授权会怎样?这里有一个例子。

有一家叫 LocationSmart 的公司。你可能从未听说过,但它与美国的每家手机运营商合作,知道这些运营商在美国的每一部手机的位置。他们将此位置数据出售给执法部门、营销机构以及希望跟踪公司设备的公司。而且你无法选择退出。这不是你的手机向 LocationSmart 或你的运营商提供信息。位置是通过手机信号塔三角测量计算的,所以你无法真正选择退出。这是一个影响几乎每个人的重大问题。

这家公司在他们的网站上提供了一个演示:你可以输入你的联系信息和电话号码,然后它会给你发送一条短信,你需要点击链接来验证“是的,我希望为这个演示暴露我的位置”,然后它会在谷歌地图上显示你的位置。老实说,我不太明白为什么他们认为这有帮助,但他们只是想展示“是的,我们确实有这些数据,我们是合法的”。在我看来,这从一开始就是个坏主意。

它的工作方式是:你的浏览器访问这个网站,你说“我想注册这个演示”,它向服务器发送一个请求。请求中包含了你想获取位置的电话号码。服务器响应,其中包含两个我想强调的字段:privacyConsentRequired: true。这基本上是说,为了获取此设备的位置,我们将向该设备发送短信,并且它必须确认愿意共享其位置。同时,它还返回一个随机令牌。该令牌用于认证。

然后,客户端发送另一个请求:“获取这个电话号码的状态”(例如,它是否已接受请求)。服务器会说:“不,实际上它还没有接受跟踪请求,订阅未激活。”浏览器会一遍又一遍地发送这个请求,实际上是忙等待。在用户实际在手机上接受之后,服务器会响应说:“是的,他们选择了加入,现在你可以请求他们的位置了。”

最后,浏览器现在知道设备已接受请求,会发送一个请求说:“这是我的令牌,我想获取这个特定电话的确切地址。”然后服务器响应说:“好的,这是位置数据”,并以 XML 格式响应。

这看起来没问题。那么问题在哪里?如果你省略中间的请求,直接跳到请求位置,如果用户尚未同意,服务器会按设计返回错误。但是,如果你更改最后一个参数:之前是 .xml(位置请求),如果你加上 .json,那么无论用户是否同意,它都会以 JSON 格式响应位置信息。

老实说,我不知道他们为什么这样设计。首先,他们同时使用 JSON 和 XML 有点奇怪(对于不熟悉的人来说,JSON 和 XML 都是结构化信息的方式,功能相同)。但他们确实两者都用。如果你更改格式,他们就会跳过授权检查。这里仍然有认证,你仍然需要提供令牌来表明“我是最初请求此位置的人”,但它甚至不检查用户是否通过手机同意了。这似乎是个大问题,影响了美国几乎所有有手机的人。

这几乎肯定是一个糟糕的复制粘贴案例。我猜他们先实现了 JSON 版本,然后改为 XML,他们复制粘贴了这个端点的代码,然后想:“哦,等等,我们忘了添加授权检查。”于是他们添加到了 XML 端点,却忘了添加到 JSON 端点,因为他们不再使用它了。这令人费解,但它确实存在。利用起来很简单,我刚才已经展示了具体方法。如果你想了解更多技术细节和背景,这里有一些很好的链接。

如何防止此类问题?

我认为更重要的是如何防止这种情况发生。标准方法(你会经常看到)是使用Web应用框架

在实现这些应用时,绝对不应该直接从文件描述符读取然后进行一些处理,那太底层了。我们更希望有一个高级库或框架来处理这些。通常,你会使用一个管理所有HTTP请求和响应的框架。你只需说:“当对这个API端点(这个路径)的请求进来时,调用我这个函数;当对那个路径的请求进来时,调用那个函数。”你可以配置这个框架,使其在调用你的应用代码之前,对每一个请求都检查认证和授权。

这样你就不会犯错,不会出现某些请求有检查而另一些没有的复制粘贴错误,因为框架被编程为对所有请求都执行这些检查,然后再调用你的应用代码。这非常有效,虽然有时仍有错误,但许多错误是由复制粘贴引起的。如果你以这种方式最小化复制粘贴,就能最小化出错的可能性。

还有一些更实验性的方法使用类型系统,有点像 Rust 确保你永远不会忘记锁或释放内存。有一些方法可以确保在语言层面,你永远不会向未经验证的端点暴露信息。你接收输入,确保在处理之前验证输入。希望我们能在课程后期讨论这个。

到目前为止,一切都清楚了吗?

第二层:保护依赖项

好的,我从来不知道这些幻灯片能讲多深。看看现在的时间,我准备的内容似乎有点多,所以我可能会跳过下一部分的一些幻灯片,但我们会根据时间来看。

好的,这是我们上节课展示的图表,这是许多分布式系统的架构方式。这里我想指出的一个关键点是:这些服务器也有IP地址。你不仅需要确保你的计算节点、应用服务器不向礼貌询问的人提供数据,还需要确保你的依赖服务器(如数据库服务器)也不向礼貌询问的攻击者提供信息。如果攻击者找到你的数据库并说“嘿,请给我你所有的数据”,而数据库说“当然,没问题,给你”,那将是一个大问题。我们应该努力解决这个问题。

具体例子:Elasticsearch

举一些具体例子。这种情况发生在各种数据库服务器上,但仅举一例,有一个叫 Elasticsearch 的数据库。它非常流行,因为它允许你存入任何类型的数据,并为你分析这些数据,你可以对数据运行任意查询。它常用于应用搜索、网站搜索(搜索就在名字里,这确实是它的设计初衷),但有时也用于指标、分析、可视化等涉及大量数据处理的地方。非常常用。

原因是它使得进行相当复杂的操作变得非常容易。你可以快速建立一个集群,数据进来就扔进集群,然后无论需要运行什么搜索、分析或查询,都可以快速在所有数据上运行。

Elasticsearch 的默认设置是只响应本地连接。你在机器上安装 Elasticsearch,只能与该机器上的 Elasticsearch 对话。它确实绑定了一个端口,但只响应来自同一台计算机的客户端。所以它使用了网络,但并没有真正与你自己机器之外的东西通信,网络范围非常有限。

如果你想在集群中使用 Elasticsearch,就像我展示的图表那样,你需要让其他机器与 Elasticsearch 机器对话。那你该怎么做?你只需更改配置,使其接受外部连接。这听起来不错吗?你只需将服务器放在互联网上,它接受来自任何人的连接,字面上任何人都可以连接到服务器并询问任何他们想要的东西。

这种情况一直在发生。我搜索了“Elasticsearch 数据泄露”,本以为会找到几篇文章,但实际上第一页的每个链接都是不同的数据泄露事件。不同的泄露:50亿条记录、12亿条记录、10亿条、5700万条、1.5万条……厄瓜多尔所有人……继续,2.5亿条微软记录(我想是客户支持记录)。翻到下一页,2400万条信贷和抵押贷款记录,听起来也很糟糕。重大泄露。我不记得这里的细节了,但找到这些泄露并不难,每年发生多次。我上周刚发现的一个泄露。

有一个网站叫“Have I Been Pwned”,如果你没听说过应该注册一下。每当你的信息出现在在线数据转储中时,它会给你发送电子邮件。我不知道这有多大帮助,你对此无能为力,它只是发邮件说“嘿,你倒霉了”,但也许知道一下也好。上周我收到一封邮件,说我的数据在一家公司的数据泄露中泄露,涉及1.03亿条记录,相当大规模的泄露。最大的转折是:我们完全不知道是哪家公司。我们完全不知道这些数据来自哪里。它是在互联网上的 Elasticsearch 实例上发现的,没人知道它属于谁,或者它是哪个集群的一部分。它就在那里,一个公共IP,完全可访问,我们不知道是谁的。它包含大量信息,甚至包括一些随机的东西,比如“由 Andy 推荐,安排木工学徒 Devon 在某日更换某街道地址的浴室盥洗台”。同样很奇怪,根据内部数据,没人能弄清楚它来自哪里。如果你想了解更多,这里有一个很好的链接讨论这次泄露。

所有这些围绕 Elasticsearch 的泄露。你可能会想,Elasticsearch 肯定有问题吧?Elasticsearch 说:“嘿,这不是我们的错。这些泄露是由于对安全性和软件工作原理的误解造成的。”如果你记得,默认设置实际上是安全的:默认情况下,数据库不会响应外部连接。但人们做了什么?他们主动错误配置安装,使其响应互联网上的任何人。人们在这里主动搬起石头砸自己的脚。

我在这里挑 Elasticsearch 的例子只是为了提供一个实例,但如果你搜索任何其他数据库技术,S3 可能是下一个想到的最大的例子,只需搜索“S3 数据泄露”,你就会发现可怕的事情。

为什么会发生这种情况?

那么,这是 Elasticsearch 的错吗?如果我们有更多时间,我很想听听你们的想法,为什么你们认为会发生这种情况。但这是我的看法,我认为发生这种情况的原因如下:

第一个原因是糟糕的默认设置。我认为我们在这方面已经取得了很大进展,但通常数据库有默认的用户名和密码,并且不要求你更改。Elasticsearch 就是这样。所以,如果你发现一个暴露的数据库,你可能知道它的凭据,因为人们懒得更改用户名和密码。MongoDB 是一个曾经有糟糕默认设置的流行数据库,它过去默认配置为接受所有连接,这很糟糕。我认为我们正在慢慢改善,MongoDB 不再那样做了。许多 MySQL 安装工具会要求你更改默认用户名和密码,但我们肯定还没有完全解决,这仍然是个问题。

第二个问题是 Elasticsearch 指出的,也是他们归咎的问题:他们说这是工程师和系统管理员的错。运行系统的人不知道自己在做什么,他们说:“嘿,我需要从不同的服务器访问我的数据库,那就向所有人开放吧。”我认为这确实是一个系统性问题。在行业的许多地方都是如此,公司优先考虑发布新功能和版本,而不是确保这些版本的安全。如果你开发了某个东西,花了很多时间试图使其安全,可能大多数人无法分辨。对于使用该应用的人来说,它看起来完全一样。因此,安全性通常得不到回报,往往是事后才考虑。我认为我们在这方面取得了一些进展,当然也有更多立法出台,比如加州最近通过了一项相当严格的隐私和安全法,旨在与欧盟的立法保持一致。但我们还有很长的路要走,我不太确定我们作为工程师整体是否在变得更好。

最后一个问题,我认为也是最有趣的一个,是我们设计的系统使得最小阻力路径是糟糕的安全性。如果你想设计一个安全的系统,你需要设计得让做错事比做对事更难,因为人们在使用你的系统时总是选择最简单的选项。如果你在设计一个数据库,你必须设计得让它更难搞砸,而不是更容易做对。我认为在许多方面,我们才刚刚开始思考这个问题。这是一个新的发展领域,在如何设计默认安全的系统方面,还有很多工作要做。

我们能做什么?

那么,我们能做些什么来避免这些问题?如果你运营一项大型服务怎么办?微软也牵涉其中,许多拥有安全团队的高知名度公司都曾因这种愚蠢的泄露而遭到入侵。

如果你经营一家大公司,拥有资源和敏感信息,你需要投入资源定期测试此类问题。你可以设置自动扫描。我去年夏天在一家公司工作,他们提供这项服务:他们与公司签约,尝试在互联网上找到该公司使用的所有服务器,以便发现“嘿,你有一个看起来包含你公司信息的 Amazon S3 存储桶,这可能不应该在互联网上”。你需要在恶意个人之前找到这些东西。你还需要聘请审计公司尝试找出系统中的弱点,实际尝试攻击它,以便发现此类问题。

另外,如果你不运营大型服务,我认为我们仍然可以做很多工作。正如我提到的,尝试找出如何改进我们无法控制的系统的安全性。你可能无法控制微软的数据库,但如果你在他们使用的数据库上工作,你可以尝试找出如何使其默认更安全。

例如,GitHub 已经开始在这方面做出一些非常有趣的贡献。他们并不运营人们的服务,只是托管他们的代码。所以很容易袖手旁观说“嘿,我对此无能为力”。但他们认真思考了这个问题,并说:“我们能想出一些创造性的解决方案来帮助解决这个问题吗?”他们开始做的一件事是扫描漏洞。他们开始扫描代码中的漏洞,如果你的代码使用了具有已知漏洞的依赖项,他们会提醒你并提交拉取请求来修复它。

这就是防御“礼貌询问”的攻击者的第二层。那么,对于那些“不礼貌询问”的攻击者呢?

第三层:防御“不礼貌询问”的攻击者

我本来打算做一个小思考实验,但我们在开始时已经做过了:如果你试图入侵一个系统,你应该总是先尝试简单的方法,比如找到明显的弱点,找到未经认证的API请求,或者尝试社会工程学。社会工程学非常有效,你可以发送钓鱼邮件,可以打电话冒充别人。这些都是大问题。

如果所有这些都失败了,下一个最好的方法是什么?我这样设计幻灯片的原因是,如果你思考这个问题,大多数人会跳到:“好吧,如果我们找不到明显的弱点,那就找新的弱点。”但实际上,你可以转向别人已经发现的弱点,即代码中的已知漏洞,并尝试利用它们。大多数时候,你甚至不需要花时间寻找缓冲区溢出,因为已经有太多现存的缓冲区溢出,而且人们非常不擅长修复它们。

案例:WannaCry 勒索软件

让我举一些出错的例子。你们有些人可能听说过名为 WannaCry 的勒索软件。它会加密你计算机上的所有文件,然后你必须支付比特币才能取回文件。幸运的是,这是那种实际上仍然保留你文件的勒索软件之一(有些勒索软件病毒会加密所有文件并要求比特币支付,但它们实际上没有解密密钥,所以你支付了比特币仍然拿不回文件)。据估计,它造成了高达40亿美元的经济损失,是2017年的一个大问题。它甚至使英国国家医疗服务体系瘫痪,导致医院不得不拒收病人。

这是怎么发生的?这完全是可以预防的,绝对完全可以预防。这是一个时间线:在2017年之前的某个时间点(我们不知道具体时间),美国国家安全局在 Windows 的文件共享堆栈中发现了一个缓冲区溢出漏洞。他们没有与微软分享,而是保留了这个漏洞,并利用它开发用于间谍活动的攻击性漏洞利用程序。三月,微软独立发现了这个漏洞,并在安全公告中发布了补丁,基本上告诉所有人:“嘿,你需要更新到这个新版本的软件,因为它修复了一个关键的安全漏洞。”四月,一个名为“影子经纪人”的黑客组织宣布他们从 NSA 窃取了这个漏洞,并将其发布在互联网上。这里的道德有点问题,我想微软已经打了补丁,但他们还是发布了。然后人们开始利用它,开发自己的恶意软件。五月,WannaCry 开始在互联网上传播。注意时间线:从补丁发布到开始传播有近两个月的时间,而且它还需要一段时间才造成最大损害。如果我们在一周内更新了系统,就完全没问题,不会有任何问题。

案例:Equifax 数据泄露

另一次泄露你可能听说过,来自一家叫 Equifax 的公司。Equifax 是一家信用监控公司。当你获取信用报告时,报告来自美国三大信用机构之一。Equifax 的数据被泄露了。我不确定是否有人确切知道所有被窃取的数据,但基本上包含了美国几乎所有有信用记录的成年人的大量敏感数据。即使你从未与他们分享过数据,甚至从未听说过这家公司,他们也拥有你的信用历史数据,这些数据被窃取了。

时间线是怎样的?这能预防吗?绝对可以。没有人想出聪明的黑客手段来入侵 Equifax 的软件,没有人开发缓冲区溢出来试图入侵 Equifax。人们只是机会主义地利用了漏洞。事情是这样的:3月7日,Apache 发布了一个针对名为 Apache Struts 的框架中漏洞的安全公告。记得我提到过,大多数网络软件都建立在为你处理 HTTP 请求的库和框架上,这就是其中之一。但它有一个漏洞,所以他们告诉所有人,如果你在使用 Apache Struts,请确保更新。猜猜谁没有更新?然后五月,攻击者开始利用这个漏洞在 Equifax 系统中获得远程代码执行权限,从而能够使用这个漏洞运行他们想要的任何代码。七月(几个月后),Equifax 终于发现他们被入侵了,但他们没有对外公布。当他们最终公开时,他们声称他们认为这不是什么大问题,所以没有宣布。但他们将此事隐瞒了七月、八月、九月,直到两个月后才最终宣布“我们被黑了”。他们的应对非常灾难性。Brian Krebs 对此有一些很好的评论,关于他们处理得多么糟糕,以及本可以如何更好地处理。是的,如果你想了解更多,这里有一些链接。再次强调,这完全是可以预防的。如果他们在这个公告发布后一两周内更新了软件,就没事了。

如何防御?

所以,更新可能很烦人,但被入侵要糟糕得多。你真的需要确保你的系统保持最新。我们在这方面取得的许多进展,就是试图找出如何让人们更定期地更新。

我认为,尝试减少攻击面也很重要。如果某个东西不需要暴露在互联网上,那就不要暴露它。如果你想避免数据库通过这些缓冲区溢出漏洞被黑客攻击,那就不要将数据库暴露在互联网上。如果你的应用程序有一部分可以只暴露给你的服务器所在的私有网络,那么你应该尝试这样做。

当然,还有零日漏洞。这肯定是个问题。最后的防线是,如果没有已知漏洞,最后的手段就是发现一个全新的漏洞。零日漏洞指的是刚刚被发现、存在零天的漏洞。要阻止这类攻击者,你必须在他们发现并利用漏洞攻击你的系统之前,找到并修复这些漏洞。这实际上非常困难。你必须花钱请人寻找这些漏洞,而且不能只是你的开发人员。开发时,你考虑的是系统应该如何被使用;而寻找漏洞时,你需要采取“系统应该如何不被使用”的思维方式:如何滥用本意做某事的代码,用它来做完全不同的事情。你真的需要花钱请人进入那种思维模式,尝试攻击你的系统并发现问题。

公司有办法做到这一点。而且,即使你确信你的软件没有缓冲区溢出或其他零日漏洞,也还不够,因为你的依赖项也可能有漏洞。

因此,谷歌建立了一个名为 Project Zero 的团队,其唯一目的就是在任何常用软件中寻找漏洞。这个链接指向 Project Zero 的博客,我强烈推荐查看。这可能是我知道的最好的技术安全博客,那里发布了很多非常有趣的东西,有些我完全看不懂,但有些还是相当容易理解的。

这就是我今天要讲的全部内容。如果你有问题,请留下来,我很乐意回答。否则,我们下周二见。祝大家周末愉快,恭喜大家度过了第七周。

是的,当然,我不知道。

我想这是最后一张幻灯片了。是的,Heartbleed 是2014年(如果我没记错的话)的一个漏洞,允许攻击者发送……首先,我应该解释一下什么是 SSL。SSL(实际上是 TLS)是一种在那些互联网“管道”上建立加密的协议。上周我展示了那个图表,你可以写入文件描述符,数据会通过这些互联网管道从另一端出来。TLS(有时称为 SSL)只是其上的另一个抽象层,当你发送到 TLS 堆栈时,数据在进入互联网管道之前被加密,然后在另一端被解密。所以,任何时候你使用 HTTPS 网站,数据都使用 TLS 加密。那么 TLS 是如何实现的呢?没人自己实现它,尝试自己实现加密是个非常糟糕的主意,总是最好使用别人的实现。TLS 最常见的实现叫做 OpenSSL,几乎每个人都在使用它。任何时候使用 SSH(我相信 OpenSSH 使用 OpenSSL),任何时候使用 HTTPS 网站,你都在使用 OpenSSL。但事实证明,在2014年,每个人都意识到 OpenSSL 有一个绝对关键的安全漏洞,允许读取任意服务器内存。幸运的是,这不是远程代码执行漏洞,你不能执行任意代码,但你可以读取任意内存。通过向服务器发送大量畸形请求,随着时间的推移,你可以提取服务器的整个内存,包括任何敏感的加密密钥或敏感信息等。这尤其糟糕,因为 OpenSSL 也用于许多不接收软件更新的嵌入式设备,例如路由器。因此,它的影响范围非常广泛,每个人都使用 SSL,而且很难修补。我敢肯定仍然存在未修补的、易受 Heartbleed 攻击的设备。

我提到这个的原因是,当这个漏洞出现时,每个人都意识到:“天哪,每个人都在使用 OpenSSL,而 OpenSSL 是一个失业的家伙在空闲时间开发的。没有团队,OpenSSL 不是一家公司,没有付费团队在这个东西上工作。”它是如此关键的基础设施,却没有公司真正维护,当时基本上只有一个人。我记得他当时还患有健康问题。所以情况很复杂。如果我没记错的话(我可能错了),我认为这就是谷歌创立 Project Zero 的原因。我想 Project Zero 大约是在那个时候开始的,因为他们意识到有大量每个人都依赖的开源软件没有得到积极维护,我们应该投入更多精力来维护和审计这些软件。

总结

在本节课中,我们一起学习了信息安全的基础知识。我们从回顾网络系统开始,理解了客户端与服务器通过协议(如HTTP)进行通信。接着,我们探讨了保护服务器需要防御的三种攻击者类型:礼貌询问的、依赖项暴露的以及不礼貌询问的。

我们深入讨论了认证(验证身份)和授权(验证权限)这两个核心安全原则,并通过 Panera、Salt 和 LocationSmart 等案例看到了缺少它们所带来的严重后果。我们还学习了如何使用 Web 应用框架来系统化地实施这些检查,避免人为错误。

然后,我们将视角扩展到系统的依赖项,特别是数据库(如 Elasticsearch),指出了错误配置和糟糕默认设置如何导致大规模数据泄露,并讨论了通过设计使系统“默认安全”的重要性。

最后,我们探讨了防御更高级攻击者的策略,包括利用已知漏洞(如 WannaCry 和 Equifax 事件)和零日漏洞。我们强调了保持软件更新、减少攻击面以及投入资源进行安全审计和漏洞挖掘的必要性。

信息安全是一个复杂且持续的挑战,但通过理解基本原则、学习过往案例并采用良好的工程实践,我们可以构建更安全、更可靠的系统。

posted @ 2026-03-29 09:45  布客飞龙I  阅读(48)  评论(0)    收藏  举报