3 分钟阅读

简介(未完成)

Python 的内存到底长什么样

char *p = malloc(1024); 发生了什么?很多人的脑子里是:malloc, 实际上 Linux 完全不是这样,malloc() 根本不会直接得到物理内存:Application ==> malloc() ==> glibc allocator ==> heap (Virtual Memory) ==> anonymous pages ==> Physical Memory (RSS)

  1. Virtual Memory。Linux 每个进程都有自己的虚拟地址空间
     0x400000
     ...
     0x7fffffffffff
    

    这些地址都是假的。CPU 真正访问时:Virtual Address ==> Page Table ==> Physical Page, 所以一个进程可以拥有100 GB虚拟地址,实际上RSS只有300 MB。

  2. Heap。Heap 是Virtual Memory中的一块区域,不是 RAM。
  3. Page。Linux 不管理1 byte/100 byte/8kb, Linux 管Page(一般4kb)。所以malloc(32)实际上可能背后变成:“申请一个 4KB page”。只是前 32B 给你剩下仍属于 allocator。
  4. RSS(Resident Set Size)。真正驻留在物理内存里的 Page。
  5. allocator 在干嘛?glibc 不会malloc ==> syscall ==> malloc ==> syscall,太慢。而是先找os mmap/brk拿一大块,然后自己切。所以 free也不是 free ==> os 回收内存,通常是free() ==> glibc ==> 放回 allocator,OS根本不知道。

对于一行 python a = {}

  1. dict ==> PyDictObject ==> PyObject allocator ==> glibc malloc ==> Virtual Memory ==> Page ==> RSS, 这就是为什么Python profiler(看的是Python Runtime ) 和 Linux profiler 看到的数字完全不同。
    1. python内存分配是一条pipeline,Python Object ==> CPython Runtime ==> malloc/free ==> Virtual Memory ==> RSS,任何一个工具,本质上都是在这条pipeline上的某一层放了一个探针。

Everything is a PyObject

Python 根本没有”变量”。只有对象(Object)和引用(Reference)。

CPython几乎所有对象都是:PyObject *, 每个 Python 对象前面都有一个公共头,

typedef struct {
    refcnt
    type
} PyObject;

例如dict 就是

+-------------------------+
| refcnt                  |
+-------------------------+
| type=dict               |
+-------------------------+
| hash table              |
+-------------------------+

del a 不是delete object,真正发生的是删除a这个引用,于是 refcnt-=1,对象还活着。只有refcnt == 0 时,del a ==> Py_DECREF() ==> tp_dealloc() ==> PyObject_Free(),对象才真正释放。为什么 Python 的释放这么快?因为refcnt实时维护,不像java 等gc。GC 不是 Python 的主要内存回收机制。Reference Count 才是。

既然有引用计数,还要 GC 干嘛?因为:循环引用。

allocator

CPython有自己的pymalloc,dict ==> PyObject_Malloc() ==> pymalloc ==> malloc() ==> Linux。真正的 malloc已经被隔了一层,为什么?因为Python 知道自己最常申请什么,所以没必要每次找 glibc。

整个 allocator有三层:Arena(256KB) ==> Pool(4KB, 与linux page 一致) ==> Block。

  1. 一个小的 PyObject 会占用一个 Block,而 Block 是固定大小的 size class,所以会有内部碎片(internal fragmentation),这是故意接受的代价,宁愿浪费一点空间,也要让分配速度极快。
    1. 大对象(比如 bytearray(10MB))超过一个阈值(通常约 512B)之后,就不会走 pymalloc,而是直接走系统 allocator,例如 malloc()。因此,大对象不会放进 Arena/Pool/Block 体系。
  2. 一个规格一个pool/每个 Pool只放一种大小的block
  3. 只要有一个block 还在,所在pool 就不会释放
  4. 只要有一个pool 还在,所在arena 就不会释放

所以很多时候del obj RSS 一点没变,不是泄露,而是allocator 缓存着。

对于一行 python a = {}dict ==> PyObject_Malloc() ==> 64B Block ==> 64B Pool ==> 256KB Arena ==> glibc ==> RSS

一个 Python Object,通常是一个”入口”,后面可能挂着很多块内存。

struct Person {
    int age;
    char name[32];
};

它在内存里就是一块连续内存。

+------------------+
| age              |
| name             |
+------------------+

Python 不是这样,d = {} ,dict 自己很小,真正的数据在另一块内存,因为Hash Table会长。所以才会有objgraph(谁引用谁) 和 Allocation Graph(谁申请了谁)的概念。Python Object 是一个根节点(Root),它引用着一棵内存树。

+----------------------+
| PyDictObject         |
+----------------------+
           │
           ▼
+----------------------+
| Hash Table           |
+----------------------+

GC

refcnt只知道有多少引用,不知道这些引用是不是已经不可达。GC不是“回收所有对象”,GC只解决Cycle,GC不关心refcnt,而是Reachable。gc.collect()只做找 Cycle ==> 释放,不会trim allocator ==> 还给 OS。

如果RSS 一直在涨,怀疑的顺序

  1. 还有引用
  2. allocator 没归还
  3. native memory
  4. GC cycle

Python 代码是如何执行的?

x = 1
y = 2
z = x + y

Python Source ==> Parser ==> AST ==> Bytecode ==> Interpreter ==> CPU, 对上面代码Python 会先编译得到Bytecode

LOAD_CONST 1
STORE_NAME x
LOAD_CONST 2
STORE_NAME y
LOAD_NAME x
LOAD_NAME y
BINARY_ADD
STORE_NAME z

Code Object/PyCodeObject 保存Bytecode

Python Function

↓

PyCodeObject

+----------------------+
| bytecode             |
| constants            |
| names                |
| locals               |
+----------------------+

PyFrameObject 负责执行

PyFrameObject

+----------------------+
| local variables      |
| stack                |
| instruction pointer  |
| code object          |
+----------------------+

比如

def add(a, b):
    c = a + b
    return c

调用 add(1, 2), Frame大概

+----------------------+
| locals               |
| a = 1                |
| b = 2                |
| c = 3                |
+----------------------+
| stack                |
+----------------------+
| current instruction  |
+----------------------+
| code object          |
+----------------------+

Fame 其实就是调用栈里的一个栈帧。foo() ==> bar() ==> baz(),对应内存

Frame(baz)
↓   调用子函数创建新的 Frame
Frame(bar)
↓
Frame(foo)
    返回,即恢复父 Frame

这就是Call Stack。 py-spy就是暂停Python,找到PyThreadState->Current Frame(每个线程都有自己的PyThreadState),然后一直往上找 Frame->previous->previous->previous,最后得到 foo ->bar -> baz。所以py-spy 根本不hook。

整个runtime就像

Interpreter
├── ThreadState
│      └── Frame
├── ThreadState
│      └── Frame
└── ThreadState
       └── Frame

Python Runtime ==> Current Frame ==> 执行一个 Frame/PyEval_EvalFrameDefault(Frame)

while (还有 bytecode)
{
    读一条
    执行一条/修改 Frame       
}

细化一点

PyObject* PyEval_EvalFrameDefault(Frame *frame)
{
    while (1) {
        opcode = NEXT_OPCODE();
        switch (opcode) {
        case LOAD_CONST:
            ...         // 修改 Frame
            break;
        case LOAD_FAST:
            ...         // 修改 Frame
            break;
        case CALL:
            ...
            break;
        case RETURN_VALUE:
            return result;
        ...
        }
    }
}

while 只负责执行,Frame 保存执行现场。while 循环每执行一步,都是在修改 Frame。例如LOAD_CONST 1 其实就是Frame.value_stack.push(1)

Python 可以在哪里 Hook?

CPython/字节码虚拟机(Bytecode VM)可以画成一条流水线(偏内存流程):Python Code ==> Parser ==> Code Object ==> Frame ==> PyEval_EvalFrameDefault() ==> PyObject_Malloc() ==> glibc malloc() ==> Linux。Hook 的本质,就是在这条流水线上选择一个位置放探针。

Python Source
      │
      ▼
 Audit Hook
      │
      ▼
 Code Object
      │
      ▼
 Frame
      │
      ├──────── Monitoring API
      │
      ├──────── sys.settrace()
      │
      ▼
 EvalFrame
      │
      ├──────── PEP523
      │
      ▼
 PyObject_Malloc
      │
      ├──────── Memray
      │
      ▼
 malloc/free
      │
      ├──────── memleak
      │
      ▼
 Linux
      │
      ├──────── eBPF
      ▼
 RSS

不要按工具来理解 Python 观测,而要按 Hook 点来理解。工具只是实现,真正决定它能力边界的是:它选择在哪一层插探针。

留下评论