在我关于无畏安全的系列文章的第二部分中,我将探讨线程安全。
今天的应用程序都是多线程的——程序不再顺序地完成任务,而是使用线程来同时执行多个任务。我们每天都在使用并发和并行处理。
- 网站同时为多个用户提供服务。
- 用户界面执行后台工作,不会中断用户。(想象一下,如果您的应用程序在每次您输入一个字符时都冻结,因为它正在进行拼写检查)。
- 一台计算机上可以同时运行多个应用程序。
虽然这可以让程序更快地完成更多工作,但也带来了一系列同步问题,即死锁和数据竞争。从安全的角度来看,我们为什么要关心线程安全?内存安全漏洞和线程安全漏洞具有相同的核心问题:无效的资源使用。并发攻击会导致与内存攻击类似的后果,包括提权、任意代码执行 (ACE) 以及绕过安全检查。
并发漏洞与实现漏洞一样,与程序正确性密切相关。虽然内存漏洞几乎总是危险的,但实现/逻辑漏洞并不总是表明安全问题,除非它们发生在代码中用于确保维护安全协议的部分(例如,允许绕过安全检查)。但是,虽然源于逻辑错误的安全问题通常发生在顺序代码中的错误附近,但并发漏洞经常发生在与其对应的漏洞不同的函数中,这使得它们难以追踪和解决。另一个复杂之处是内存错误处理和并发缺陷之间的重叠,我们在数据竞争中可以看到这一点。
编程语言已经发展了不同的并发策略,帮助开发者管理多线程应用程序的性能和安全挑战。
并发问题
一个常见的公理是并行编程很难——我们的大脑更擅长顺序推理。并发代码可能会在线程之间产生意想不到且不希望出现的交互,包括死锁、竞争条件和数据竞争。
死锁发生在多个线程都等待对方采取某些操作才能继续执行时,导致线程永久阻塞。虽然这是一种不希望出现的行为,可能会导致拒绝服务攻击,但它不会造成诸如 ACE 之类的漏洞。
竞争条件是指任务的时间安排或顺序可能影响程序正确性的情况,而数据竞争发生在多个线程尝试同时访问内存中的同一位置并且至少其中一个访问是写操作时。数据竞争与竞争条件之间有很多重叠,但它们也可以独立发生。没有良性数据竞争。
并发漏洞的潜在后果
- 死锁
- 信息丢失:另一个线程覆盖信息
- 完整性丢失:来自多个线程的信息交织在一起
- 活性丢失:由于对共享资源的不均衡访问导致的性能问题
最著名的并发攻击类型称为TOCTOU(检查时间到使用时间)攻击,它是在检查条件(如安全凭据)和使用结果之间的一种竞争条件。TOCTOU 攻击是完整性丢失的示例。
死锁和活性丢失被认为是性能问题,而不是安全问题,而信息丢失和完整性丢失都更有可能与安全相关。这份来自 Red Balloon Security 的论文研究了一些可利用的并发错误。一个例子是指针损坏,它允许提权或远程执行——一个加载共享 ELF(可执行和可链接格式)库的函数第一次调用时正确地保存信号量,但第二次调用时却没有,导致内核内存损坏。此攻击是信息丢失的示例。
并发编程中最棘手的部分是测试和调试——并发漏洞的再现性很差。事件计时、操作系统决策、网络流量等都可能导致每次运行包含并发漏洞的程序时产生不同的行为。
不仅并发程序的每次运行都可能产生不同的行为,而且插入打印或调试语句也会改变行为,导致海森堡错误(并发编程中常见的非确定性、难以再现的错误)神秘地消失。这些操作与其他操作相比很慢,会相应地改变消息交错和事件计时。
并发编程很难。预测并发代码如何与其他并发代码交互非常困难。当出现错误时,它们很难找到和修复。与其依靠程序员来担心这个问题,不如让我们看看设计程序和使用语言来简化编写并发代码的方法。
首先,我们需要定义“线程安全”的含义
“如果数据类型或静态方法在从多个线程使用时表现正确,无论这些线程如何执行,并且不需要调用代码进行额外协调,那么它就是线程安全的。” MIT
编程语言如何管理并发
在没有静态强制线程安全的语言中,程序员在与可能与另一个线程共享的内存交互时,必须始终保持警惕,并且这些内存可能随时改变。在顺序编程中,我们被教导避免使用全局变量,以防代码的其他部分对其进行了静默修改。与手动内存管理一样,要求程序员安全地修改共享数据是有问题的。
一般来说,编程语言在管理安全并发方面仅限于两种方法
- 限制可变性或限制共享
- 手动线程安全(例如,锁、信号量)
限制线程的语言要么将可变变量限制在一个线程中,要么要求所有共享变量都是不可变的。这两种方法都消除了数据竞争的核心问题——不正确地修改共享数据——但这可能过于限制。为了解决这个问题,语言引入了低级同步原语,例如互斥锁。这些可以用来构建线程安全的的数据结构。
Python 和全局解释器锁
Python 的参考实现 CPython 拥有一个名为全局解释器锁 (GIL) 的互斥锁,它只允许单个线程访问 Python 对象。多线程 Python 因 GIL 的获取等待时间而臭名昭著效率低下。因此,大多数并行 Python 程序使用多进程,这意味着每个进程都有自己的 GIL。
Java 和运行时异常
Java 被设计为通过共享内存模型支持并发编程。每个线程都有自己的执行路径,但可以访问程序中的任何对象——程序员需要使用 Java 内置原语来同步线程之间的访问。
虽然 Java 拥有创建线程安全程序的基本元素,但线程安全并非由编译器保证(与内存安全不同)。如果发生未同步的内存访问(即数据竞争),那么 Java 将引发运行时异常——但是,这仍然依赖于程序员适当地使用并发原语。
C++ 和程序员的大脑
虽然 Python 通过 GIL 同步所有内容来避免数据竞争,Java 在检测到数据竞争时会引发运行时异常,而 C++ 则依赖于程序员手动同步内存访问。在 C++11 之前,标准库不包含并发原语。
大多数编程语言为程序员提供了编写线程安全代码的工具,并且存在用于检测数据竞争和竞争条件的事后方法;但是,这不会导致任何线程安全或数据竞争自由的保证。
Rust 如何管理并发?
Rust 采用多管齐下的方法来消除数据竞争,使用所有权规则和类型安全来保证在编译时数据竞争自由。
本系列文章的第一篇文章介绍了所有权——Rust 的核心概念之一。每个变量都有一个唯一的拥有者,并且可以被移动或借用。如果另一个线程需要修改资源,那么我们可以通过将变量移动到新线程来转移所有权。
移动强制执行排他,允许多个线程写入同一内存,但永远不会同时写入。由于拥有者被限制在一个线程中,如果另一个线程借用了一个变量会发生什么?
在 Rust 中,您可以拥有一个可变借用或任意多个不可变借用。您永远不能同时拥有一个可变借用和一个不可变借用(或多个可变借用)。当我们谈论内存安全时,这确保了资源被正确释放,但当我们谈论线程安全时,这意味着一次只有一个线程可以修改变量。此外,我们知道没有其他线程会尝试引用过时的借用——借用要么强制执行共享,要么强制执行写入,但绝不会两者兼而有之。
所有权被设计用来减轻内存漏洞。事实证明,它也阻止了数据竞争。
虽然许多编程语言都有方法来强制执行内存安全(如引用计数和垃圾回收),但它们通常依赖于手动同步或禁止并发共享来防止数据竞争。Rust 的方法通过尝试解决识别有效资源使用并强制执行编译时的有效性来解决这两种安全问题。
但是等等!还有更多!
所有权规则阻止多个线程写入同一内存,并禁止线程与可变性之间的同时共享,但这并不一定提供线程安全的数据结构。Rust 中的每个数据结构要么是线程安全的,要么不是。这使用类型系统传达给编译器。
一个类型良好的程序不会出错。罗宾·米尔纳,1978 年
在编程语言中,类型系统描述了有效的行为。换句话说,一个类型良好的程序是定义良好的。只要我们的类型足够表达来捕获我们预期的含义,那么一个类型良好的程序将按照预期的方式运行。
Rust 是一种类型安全的语言——编译器会验证所有类型的一致性。例如,以下代码将无法编译
let mut x = "I am a string";
x = 6;
error[E0308]: mismatched types
--> src/main.rs:6:5
|
6 | x = 6; //
| ^ expected &str, found integral variable
|
= note: expected type `&str`
found type `{integer}`
Rust 中的所有变量都具有类型——通常,它们是隐式的。我们也可以定义新的类型,并使用 特征系统 描述类型具有的功能。特征在 Rust 中提供了一种接口抽象。两个重要的内置特征是 Send
和 Sync
,它们由 Rust 编译器为 Rust 程序中的每种类型默认公开
Send
表示结构体可以安全地在线程之间发送(所有权移动所需)Sync
表示结构体可以安全地在线程之间共享
此示例是 标准库代码 的简化版本,该代码用于生成线程
fn spawn<Closure: Fn() + Send>(closure: Closure){ ... }
let x = std::rc::Rc::new(6);
spawn(|| { x; });
spawn
函数接受一个参数 closure
,并要求 closure
的类型实现 Send
和 Fn
特征。当我们尝试生成一个线程并传递一个使用变量 x
的闭包值时,编译器会因为未满足这些要求而拒绝程序,并出现以下错误
error[E0277]: `std::rc::Rc<i32>` cannot be sent between threads safely
--> src/main.rs:8:1
|
8 | spawn(move || { x; });
| ^^^^^ `std::rc::Rc<i32>` cannot be sent between threads safely
|
= help: within `[closure@src/main.rs:8:7: 8:21 x:std::rc::Rc<i32>]`, the trait `std::marker::Send` is not implemented for `std::rc::Rc<i32>`
= note: required because it appears within the type `[closure@src/main.rs:8:7: 8:21 x:std::rc::Rc<i32>]`
note: required by `spawn`
Send
和 Sync
特征 允许 Rust 类型系统推断哪些数据可以共享。通过将此信息包含在类型系统中,线程安全就变成了类型安全。与其依赖于文档,线程安全是编译器法律的一部分。
这允许程序员对可以在线程之间共享的内容有自己的看法,而编译器将强制执行这些看法。
虽然许多编程语言提供了并发编程的工具,但防止数据竞争是一个难题。要求程序员推断复杂的指令交织和线程之间的交互会导致易错的代码。虽然线程安全和内存安全违反会产生类似的后果,但传统的内存安全缓解措施(如引用计数和垃圾收集)并不能防止数据竞争。除了静态地保证内存安全外,Rust 的所有权模型还防止了线程间的不安全数据修改和共享,而类型系统在编译时传播和强制执行线程安全。
一条评论