CPython runtime
简介(未完成)
Python 的内存到底长什么样
char *p = malloc(1024); 发生了什么?很多人的脑子里是:malloc, 实际上 Linux 完全不是这样,malloc() 根本不会直接得到物理内存:Application ==> malloc() ==> glibc allocator ==> heap (Virtual Memory) ==> anonymous pages ==> Physical Memory (RSS)
- Virtual Memory。Linux 每个进程都有自己的虚拟地址空间
0x400000 ... 0x7fffffffffff这些地址都是假的。CPU 真正访问时:Virtual Address ==> Page Table ==> Physical Page, 所以一个进程可以拥有100 GB虚拟地址,实际上RSS只有300 MB。
- Heap。Heap 是Virtual Memory中的一块区域,不是 RAM。
- Page。Linux 不管理1 byte/100 byte/8kb, Linux 管Page(一般4kb)。所以
malloc(32)实际上可能背后变成:“申请一个 4KB page”。只是前 32B 给你剩下仍属于 allocator。 - RSS(Resident Set Size)。真正驻留在物理内存里的 Page。
- allocator 在干嘛?glibc 不会
malloc ==> syscall ==> malloc ==> syscall,太慢。而是先找os mmap/brk拿一大块,然后自己切。所以 free也不是free ==> os 回收内存,通常是free() ==> glibc ==> 放回 allocator,OS根本不知道。
对于一行 python a = {}
- dict ==> PyDictObject ==> PyObject allocator ==> glibc malloc ==> Virtual Memory ==> Page ==> RSS, 这就是为什么Python profiler(看的是Python Runtime ) 和 Linux profiler 看到的数字完全不同。
- 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。
- 一个小的 PyObject 会占用一个 Block,而 Block 是固定大小的 size class,所以会有内部碎片(internal fragmentation),这是故意接受的代价,宁愿浪费一点空间,也要让分配速度极快。
- 大对象(比如
bytearray(10MB))超过一个阈值(通常约 512B)之后,就不会走 pymalloc,而是直接走系统 allocator,例如 malloc()。因此,大对象不会放进 Arena/Pool/Block 体系。
- 大对象(比如
- 一个规格一个pool/每个 Pool只放一种大小的block
- 只要有一个block 还在,所在pool 就不会释放
- 只要有一个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 一直在涨,怀疑的顺序
- 还有引用
- allocator 没归还
- native memory
- 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 点来理解。工具只是实现,真正决定它能力边界的是:它选择在哪一层插探针。
留下评论