Blog

对象丢失问题

对象丢失问题 #

GC 线程 / 协程与应用线程 / 协程是并发执行的,在 GC 标记 worker 工作期间,应用还会不断地修改堆上对象的引用关系,这就可能导致对象丢失问题。下面是一个典型的应用与 GC 同时执行时,由于应用对指针的变更导致对象漏标记,从而被 GC 误回收的情况。

在这张图表现的 GC 标记过程中,应用动态地修改了 A 和 C 的指针,让 A 对象的内部指针指向了 B,C 的内部指针指向了 D。如果标记过程垃圾收集器无法感知到这种变化,最终 B 对象在标记完成后是白色,会被错误地认作内存垃圾被回收。

Viewpoints #

From #

大咖助阵|曹春晖:聊聊 Go 语言的 GC 实现

什么是漏标 漏标与对象丢失问题讲的是同一件事情。

语义垃圾(Semantic Garbage)

Content #

语义垃圾(semantic garbage),有些场景也被称为内存泄露,指的是从语法上可达(可以通过局部、全局变量被引用)的对象,但从语义上来讲他们是垃圾,垃圾回收器对此无能为力。

我们来看一个语义垃圾在 Go 语言中的实例:

这里,我们初始化了一个 slice,元素均为指针,每个指针都指向了堆上 10MB 大小的一个对象。

当这个 slice 缩容时,底层数组的后两个元素已经无法再访问了,但它关联的堆上内存依然是无法释放的。

Viewpoints #

From #

大咖助阵|曹春晖:聊聊 Go 语言的 GC 实现

SECTION段定义语句中使用vstart子句

Content #

NASM的分段程序中,默认情况下,引用某个标号时,该标号的汇编地址是从整个程序的起始位置开始的偏移。

section a
        mov ax,1
        mov ax,2
    a_label:
        mov ax,3
section b
        mov ax,4
        mov ax,5
    b_label:
        mov ax,6
b_label:

b_label的值是从section a的开始位置开始的偏移,值为0x12,表示距离开始位置18个字节。

为了获取b_label距离当前段起始位置段的偏移量,这个时候就需要加vstart=0,代码如下:

section a
        mov ax,1
        mov ax,2
    a_label:
        mov ax,3
section b vstart=0
        mov ax,4
        mov ax,5
    b_label:
        mov ax,6
b_label:	;此时该位置的值是0x06,说明距离段b的距离是6个字节

Viewpoint #

From #

基址寻址与变址寻址

基址寻址 #

基址寻址指的是在指令的地址部分使用基址寄存器BX或BP来提供偏移地址。比如:

mov [bx], dx
inc word [bx]

处理器会访问由段寄存器DS指向的数据段,BX的值作为偏移地址。

函数调用时为了访问栈底的参数,可以使用BP。比如:

mov ax, 0x5000
push ax
mov bp, sp
mov ax, 0x7000
push ax
mov dx, [bp] ;用bp取出了栈底的数据

此时,处理器会访问由段寄存器SS指向的数据段,BP的值作为偏移地址。

变址寻址 #

变址寻址与基址寻址很相似,唯一的区别在于变址寻址使用变址寄存器(或称索引寄存器)SI和DI。例如:

mov [si], dx
add ax, [di]

处理器会访问由段寄存器DS指向的数据段,SI或DI的值作为偏移地址。

From #

井号和双井号在宏定义中的用法

Content #

使用“gcc -E”命令对以下程序进行预处理,并解释预处理的结果,以此来掌握井号和双井号在宏定义中的作用。

#include <stdio.h>

#define TYPE_Apple 1
#define TYPE_Pear  2

struct Fruit {
    int _type;
    char* _name;
};

#define DECLARE(x) \
struct Fruit x = {      \
    TYPE_##x,           \
    #x,                 \
};

DECLARE(Apple)
DECLARE(Pear)

int main() {
    printf("%d, %s\n", Apple._type, Apple._name);
    printf("%d, %s\n", Pear._type, Pear._name);
    return 0;
}

预处理的核心工作就是对宏定义进行展开,展开的时候主要是进行字符串的直接替换,尤其是对于宏函数,不能把它真的当成函数进行处理。

拼接的结果:

struct Fruit Apple = { 1, "Apple", };
struct Fruit Pear = { 2, "Pear", };

Viewpoint #

From #

大咖助阵|海纳:C 语言是如何编译执行的?(一)

...

为什么循环嵌套的改变会影响性能

为什么循环嵌套的改变会影响性能 #

说完了分支预测,现在我们先来看一个 Java 程序。

public class BranchPrediction {
    public static void main(String args[]) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            for (int j = 0; j <1000; j ++) {
                for (int k = 0; k < 10000; k++) {
                }
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start));

        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            for (int j = 0; j <1000; j ++) {
                for (int k = 0; k < 100; k++) {
                }
            }
        }
        end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start) + "ms");
    }
}

对应的命令行输出。

...

Pipeline Zap(Flush)

Content #

最简单的分支预测技术,叫作“假装分支不发生”。顾名思义,自然就是仍然按照顺序,把指令往下执行。其实就是 CPU 预测,条件跳转一定不发生。这样的预测方法,其实也是一种静态预测技术。就好像猜硬币的时候,你一直猜正面,会有 50% 的正确率。

如果分支预测是正确的,我们自然赚到了。这个意味着,我们节省下来本来需要停顿下来等待的时间。如果分支预测失败了呢?那我们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。

CPU 不仅要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,我们还需要做对应的清除操作。比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。

所以,CPU 需要提供对应的丢弃指令的功能,通过控制信号清除掉已经在流水线中执行的指令。只要对应的清除开销不要太大,我们就是划得来的。

Viewpoint #

From #

25 | 冒险和预测(四):今天下雨了,明天还会下雨么?

内存的四级分配结构

内存分配的数据结构之间的关系 #

arenas 是 Go 向操作系统申请内存时的最小单位,每个 arena 为 64MB 大小,在内存中可以部分连续,但整体是个稀疏结构。

单个 arena 会被切分成以 8KB 为单位的 page,由 page allocator 管理,一个或多个 page 可以组成一个 mspan,每个 mspan 可以按照 sizeclass 再划分成多个 element。同样大小的 mspan 又分为 scan 和 noscan 两种,分别对应内部有指针的 object 和内部没有指针的 object。

我们可以将内存分配的路径与 CPU 的多级缓存作类比,这里 mcache 内部的 tiny 可以类比为 L1 cache,而 alloc 数组中的元素可以类比为 L2 cache,全局的 mheap.mcentral 结构为 L3 cache,mheap.arenas 是 L4,L4 是以页为单位将内存向下派发的,由 pageAlloc 来管理 arena 中的空闲内存。

Viewpoint #

From #

大咖助阵|曹春晖:聊聊 Go 语言的 GC 实现

...

内存管理的三个参与者

内存管理的三个参与者 #

当讨论内存管理问题时,我们主要会讲三个参与者,mutator,allocator 和 garbage collector。

  1. mutator 指的是我们的应用,也就是 application,我们将堆上的对象看作一个图,跳出应用来看的话,应用的代码就是在不停地修改这张堆对象图里的指向关系。
  2. allocator 指的是内存分配器,应用需要内存的时候都要向 allocator 申请。 allocator 要维护好内存分配的数据结构,在多线程场景下工作的内存分配器还需要考虑高并发场景下锁的影响,并针对性地进行设计以降低锁冲突。
  3. collector 是垃圾回收器。死掉的堆对象、不用的堆内存都要由 collector 回收,最终归还给操作系统。当 GC 扫描流程开始执行时,collector 需要扫描内存中存活的堆对象,扫描完成后,未被扫描到的对象就是无法访问的堆上垃圾,需要将其占用内存回收掉。

三者的交互过程可以用下图来表示:

应用需要在堆上申请内存时,会由编译器帮程序员自动调用 runtime.newobject,这时 allocator 会使用 mmap 这个系统调用从操作系统中申请内存,若 allocator 发现之前申请的内存还有富余,会从本地预先分配的数据结构中划分出一块内存,并把它以指针的形式返回给应用。在内存分配的过程中, allocator 要负责维护内存管理对应的数据结构。

而 collector 要扫描的就是 allocator 管理的这些数据结构,应用不再使用的部分便应该被回收,通过 madvise 这个系统调用返还给操作系统。

Viewpoint #

From #

大咖助阵|曹春晖:聊聊 Go 语言的 GC 实现

逃逸分析

Content #

在传统的不带 GC 的编程语言中,我们需要关注对象的分配位置,要自己去选择对象是分配在堆还是栈上,但在 Go 这门有 GC 的语言中,集成了逃逸分析功能来帮助我们自动判断对象应该在堆上还是栈上,我们可以使用 go build -gcflags="-m" 来观察逃逸分析的结果:

package main

func main() {
    var m = make([]int, 10240)
    println(m[0])
}

较大的对象也会被放在堆上。

❯ go build -gcflags="-m" escape.go
# command-line-arguments
./escape.go:3:6: can inline main
./escape.go:4:14: make([]int, 10240) escapes to heap

这里,执行 gcflags=“-m” 的输出,我们就可以看到发生了逃逸。如果将m所需的空间修改为1024,将不会有逃逸发生,m会被分配在栈上。

若对象被分配在栈上,它的管理成本就比较低,我们通过挪动栈顶寄存器就可以实现对象的分配和释放。若对象被分配在堆上,我们就要经历层层的内存申请过程。但这些流程对用户都是透明的,在编写代码时我们并不需要在意它。只有需要优化时,我们才需要研究具体的逃逸分析规则。

Viewpoint #

From #

大咖助阵|曹春晖:聊聊 Go 语言的 GC 实现