🤔 由20行Go代码引发的思考:内存安全还是线程安全?如何写出真正安全的代码?

188 阅读7分钟

长期以来,程序员界存在一个"常识":内存安全和线程安全是两码事。内存安全指的是不会出现use-after-free或数组越界这类问题,线程安全则是防止并发编程中的竞态条件。大多数人认为这是两个独立的安全领域。

但事实是:这种区分毫无意义,因为线程不安全的语言根本无法提供真正的内存安全

为了证明这个观点,我写了段Go代码,直接让它segfault崩溃在0x2a地址(就是十进制42)。注意这不是普通的Go panic,而是真正的段错误。

先看问题代码

package main

// 随便定义个接口
type Thing interface {
    get() int
}

// 两个实现,字段类型完全不同
type Int struct {
    val int
}
func (s *Int) get() int {
    return s.val
}

type Ptr struct {
    val *int
}
func (s *Ptr) get() int {
    return *s.val
}

// 全局接口变量,会在Int和Ptr之间切换
var globalVar Thing = &Int{val: 42}

// 不停调用接口方法
func repeat_get() {
    for {
        x := globalVar
        x.get()
    }
}

// 不停切换动态类型
func repeat_swap() {
    var myval = 0
    for {
        globalVar = &Ptr{val: &myval}
        globalVar = &Int{val: 42}
    }
}

func main() {
    go repeat_get()
    repeat_swap()
}

运行这代码,很快就会看到:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x2a pc=0x468863]

注意那个地址0x2a,正好是42的十六进制。程序试图把整数42当指针解引用了。

攻击原理:接口的致命弱点

Go接口的内部秘密

很多Go新手以为接口就是个简单的变量,其实不然。Go把接口类型存储为一个叫iface的结构体,里面有两个指针:

type iface struct {
    tab  *itab    // 指向类型信息和方法表
    data unsafe.Pointer  // 指向实际数据
}

想象一下,接口变量就像一张双面名片:

  • 正面(tab):写着"我是什么类型,有哪些方法"
  • 反面(data):指向真正的数据在哪里

问题出在哪里?

当你给接口变量赋新值时,Go需要分别更新这两个指针。关键来了:这两次更新不是原子操作

// 当执行这行代码时
globalVar = &Ptr{val: &myval}

// Go实际做的是:
// 步骤1:更新data指针 -> 指向Ptr的数据
// 步骤2:更新tab指针 -> 指向Ptr的方法表
// 问题:步骤1和2之间有时间缝隙!

时序攻击的精妙之处

我设置了两个并发goroutine制造竞争:

  • goroutine A(repeat_get):疯狂读取globalVar并调用方法
  • goroutine B(repeat_swap):疯狂在Int和Ptr之间切换

时序就是一切。假设发生这样的交错:

时刻1: goroutine B开始更新globalVar = &Ptr{...}
       -> 只更新了data指针,tab还是旧的Int类型
时刻2: goroutine A读取globalVar
       -> 拿到了Ptr的data + Int的tab(混搭!)
时刻3: goroutine A调用x.get()
       -> 用Int的方法表,但操作Ptr的数据

灾难是怎么发生的

现在程序彻底懵了:

  1. 它以为自己在处理Int类型(因为方法表是Int的)
  2. 但实际数据是Ptr类型的结构
  3. Int.get()方法期望直接返回val字段的值
  4. 但现在val字段是个指针(Ptr的结构)
  5. 程序把这个指针的值(42)当作内存地址去访问
  6. 尝试访问地址0x2a(42的十六进制) → 段错误!

为什么这么危险?

这就像医院搞错了病历:拿着张三的CT片,按李四的手术方案做手术。结果可想而知。

更可怕的是,稍加修改这个攻击,你可以:

  • 读取任意内存地址的内容
  • 往任意地址写入数据
  • 完全绕过Go的类型系统保护

这就是为什么我说Go的"内存安全"其实建立在"没有数据竞争"这个不稳定的前提上。

其他语言怎么处理

Java:付出巨大代价的完美方案

Java开发者花费巨大努力确保即使有数据竞争,程序依然保持完全定义良好的行为。他们甚至开发了第一个工业级部署的并发内存模型,比C++11内存模型早很多年。

结果就是,并发Java程序中你可能看到意料之外的旧值(比如本该初始化的引用变成null),但永远不可能真正"破坏"语言本身,不会解引用无效指针然后段错误。

从某种意义上说,所有Java程序都是线程安全的

Rust:类型系统的革命

Rust用强大的类型系统完全排除大多数访问上的数据竞争,只为少数内存访问承担安全处理竞争的成本。Swift现在也在用类似的"严格并发"方法。

Go:两头不靠的尴尬

很不幸,Go两条路都没选。严格来说算不上内存安全语言。Go能承诺的最好情况是:如果程序没有数据竞争(或者更准确地说,在接口、切片、映射这些问题类型上没有数据竞争),那么内存访问不会出错。

这就像卖保险说:"只要你不出意外,我们就赔意外险。"

🔍 Go的真实现状

检测工具的局限

公平地说,Go确实提供了开箱即用的竞争检测工具,能快速发现问题。但在真实程序中,你必须希望测试套件覆盖所有可能情况——而这正是强类型系统和静态安全保证要避免的问题。

go test -race ./...
go build -race

官方文档的"春秋笔法"

Go内存模型文档强调"大多数竞争只有有限数量的结果",说Go不像"C和C++那样,任何有竞争的程序含义都完全未定义"。

你可以说"大多数"是在暗示什么,但这节没列出任何结果无限的情况,很容易被忽略。他们甚至说Go"更像Java或JavaScript",考虑到这些语言为实现线程安全所做的努力,这种说法相当不公平。

只有后面的小节才明确承认Go中某些竞争确实有完全未定义的行为(这与Java或JavaScript非常不同)。

🎭 重新定义"内存安全"

我认为人们谈论内存安全时真正关心的是程序不能破坏语言本身。所有安全漏洞都是代码做了语言规范中不可能的事情,比如跳转到用户数组并当汇编执行。

我们通常用 未定义行为 Undefined Behavior 描述这种破坏语言的程序。一旦程序有UB,一切都没保障;攻击者能否控制UB的具体表现并利用,主要是实现细节。

C11和C++11标准明确规定,包含数据竞争的C或C++程序具有未定义行为。Java的关键差异是,数据竞争仅影响"线程间操作"而不是未定义行为,这意味着Java程序可能产生不期望的并发行为但仍然安全。

在我看来,存在明确界线将"安全"语言(程序不能有未定义行为)和"不安全"语言分开。没意义进一步细分为内存安全、线程安全、类型安全等等——不管为什么有UB,重要的是有UB的程序违背语言基本抽象,这是漏洞的完美温床。

因此,如果一门语言不能系统性防止未定义行为,我们就不应该称其"内存安全"

最后

这不是要黑Go,而是要把语言的鲜为人知弱点放在聚光灯下。在选择编程语言时,我们需要明确了解每种语言提供的安全保证。

记住:真正的安全不是部分的,而是整体的。


引用来源:

Ralf Jung - There is no memory safety without thread safety - ralfj.de
www.ralfj.de/blog/2025/0…

The Go Team - The Go Memory Model - The Go Programming Language
go.dev/ref/mem

Dave Cheney - Ice cream makers and data races
dave.cheney.net/2014/06/27/…

Russ Cox - Go Data Structures: Interfaces
research.swtch.com/interfaces

The Go Team - Data Race Detector - The Go Programming Language
go.dev/doc/article…