파이썬과 메모리 (중급)

L-cloud·2023년 4월 12일
2

파이썬

목록 보기
3/4
post-thumbnail

🚧 이 문서에서 이야기하는 파이썬은 Cpython입니다! 🚧 

본 글에서 알아볼 지식
1. 파이썬에서 “객체”는 무엇이고 메모리 어느 영역에, 어떻게 저장되는가
2. 파이썬의 GC는 어떻게 동작하는가 (간결하게)
3. 파이썬의 메모리 구조

파이썬에서 “객체”는 무엇이고 메모리 어느 영역에, 어떻게 저장되는가

파이썬의 장점은 메모리를 사용자가 직접 관리 할 필요가 없다는 것입니다.

파이썬 스스로 메모리 할당과 해제를 해주기 때문이지요! 하지만 파이썬이 어떻게 메모리를 관리하는지 안다면 파이썬을 더욱 자세히 알 수 있고, 더 효율적인 코드 작성이 가능해집니다.

더 Pythoic한 코드 작성을 위해 모험을 떠나 봅시다.

우선 파이썬에서 모든 것은 객체라는 사실을 기억해야 합니다. int도 파이썬에서는 객체가 됩니다.

>>> a = 42
>>> a.__mul__(2)
84

조금 자세히 살펴볼까요? 파이썬에서 객체는 PyObejct로 이루어져 있습니다.

typedef struct _object {
    _PyObject_HEAD_EXTRA  /* 디버깅때 사용됨 */
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

# https://github.com/python/cpython/blob/f320be7/Include/object.h#L106

그림으로 도식화해 보면 아래처럼 되겠지요.

object -----> +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ \
              |                    ob_refcnt                  | |
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | PyObject_HEAD
              |                    *ob_type                   | |
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ /
              |                      ...                      |

파이썬 객체의 특징은 여러가지가 있는데요. 그중 우리는 아래 2가지를 살펴봅시다.

  1. 객체는 Heap 영역에 할당된다.
  2. 객체의 타입과 데이터, 메모리 주소, 메모리 크기 등은 생성되는 순간 Fixed 된다.

1번을 먼저 예시와 함께 구체적으로 살펴봅시다. 아래와 같은 코드는 어떻게 구성이 될까요?

x = 1234

아래는 파이썬에 정의되어있는 integer object의 structure입니다.

struct _longobject {
    PyObject_VAR_HEAD
    digit ob_digit[1];
};
#https://github.com/python/cpython/blob/3.11/Include/cpython/longintrepr.h

PyObject_VAR_HEAD 는 아래 처럼 구성되어 있습니다.

typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;

위 과정을 모두 거치면 아래와 같은 struct 가 구성됩니다!

struct _longobject {
    long ob_refcnt;
    PyTypeObject *ob_type;
    size_t ob_size;
    long ob_digit[1];
};

ob_refcnt 는 파이썬에서 메모리를 관리할 때 사용되며 이 객체가 몇 번이나 참조되었는가를 나타냅니다. 이는 뒤에서 자세히 다룰 예정입니다. ob_type 은 해당 타입 객체의 정보와 메소드 정보를 가리키는 포인터입니다. 여기에서 더 자세히 확인하실 수 있습니다. 또한 ob_type마다 서로 다른 memory allocator와 deallocator를 가지고 있습니다.

  • ob_size는 사이즈 정보입니다. 글의 범위를 조금 벗어나기는 했지만 int 타입에서 ob_size가 어떻게 사용되는지 살짝 살펴봅시다. 파이썬의 경우 C, JAVA와 다르게 큰 int 도 stackoverflow가 발생하지 않는데 이는 ob_size와 연관이 있습니다. 예시를 통해 봅시다. 아주 큰 수인 123456789101112131415를 파이썬에서는 아래와 같이 저장 합니다.
    ob_size3
    ob_digit43797691987719511107
    계산 식은 (437976919 2^(30**0)) + (87719511 2^(30*1)) + (107 2^(30*2)) 이렇게 입니다. 자세한 설명은 여기여기를 참고해 주세요.

그렇다면 그림으로 x = 1234 이 메모리에 올라와 있는 것을 표현해보면 어떻게 될까요? x라는 변수에는 C언어처럼 123456이라는 데이터가 저장된 위치가 저장되는 것이 아니라 Reference가 저장되겠죠!

2번 특징은 직관적으로 잘 받아들여지지 않습니다.

??? : 파이썬 리스트 C나 Java array와 다르게 계속 원소가 추가 되고 타입도 마음대로 변경이 되는데요?

이는 파이썬에서 자체적으로 리스트의 메모리를 늘려가며 새로 할당하기 때문입니다!

# 아래는 정확한 내용이 아닙니다! 
# 동작 방식만 나타내었을 뿐 실제 리스트의 메모리 재할당 규칙은 Cpython github 코드를 참조해 주세요!
# 0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ... */
a = [1,2] -> # 처음에 크기 4를 할당함
a.append(3)
a.append(4)
a.append(5)
...
# 처음 할당받은 크기를 초과한 경우 새로 메모리를 할당받고 기존 리스트를 모두 새로운 영역에 복사 후 추가

# 실제 리스트는 70% 정도 영역이 채워졌을 때 크기를 늘리는 것으로 기억합니다.
# 리스트 내의 원소를 복사하는 것이 아니라 주소를 복사합니다. 
# 객체의 타입과 데이터, 메모리 주소, 메모리 크기 등은 생성되는 순간 Fixed 되기 때문이지요!

print(type(a)) # <class 'List'>

a = 3

print(type(a)) # <class 'int'> # 새로운 객체 가리킴

더 자세한 내용은 여기깃허브를 참조해 주세요.

ListObject의 struct를 보며 위 과정을 자세히 살펴봅시다.

/* https://github.com/python/cpython/blob/3.11/Include/cpython/listobject.h */
typedef struct {
    PyObject_VAR_HEAD
    /* Vector of pointers to list elements.  list[0] is ob_item[0], etc. */
    PyObject **ob_item;

    /* ob_item contains space for 'allocated' elements.  The number
     * currently in use is ob_size.
     * Invariants:
     *     0 <= ob_size <= allocated
     *     len(list) == ob_size
     *     ob_item == NULL implies ob_size == allocated == 0
     * list.sort() temporarily sets allocated to -1 to detect mutations.
     *
     * Items must normally not be NULL, except during construction when
     * the list is not yet visible outside the function that builds it.
     */
    Py_ssize_t allocated;
} PyListObject;

ob_item 에 이중 포인터가 사용되면서 리스트 인덱스의 메모리 주소에 직접 접근하지 않고 메모리 주소가 담긴 배열을 통해 접근하고 있습니다. 따라서 파이썬 리스트에서는 실제 메모리가 저장되는 위치가 순차적이지 않을 가능성이 있고, 다양한 타입을 리스트에 저장할 수 있습니다.

그럼 a = [1,2,3,'k'] 를 간략히 그림으로 표현하면 대략 아래 그림과 같은 모습일 수 있겠죠.

(64bit 운영체제 기준입니다. 그림에 나와있는 주소는 포인터가 가리키는 위치의 주소가 아닌 포인터 배열의 주소를 표현한 것입니다.)

아래처럼 리스트에 새로운 원소를 추가해서 처음 할당받은 크기를 초과한 경우를 그림으로 표현해 봅시다.

a.append(10)
a.append(11)
....

아래처럼 새로운 포인터 배열이 할당된 후 기존 값을 복사하는 배열을 하나 만들겠죠.

이제 새로운 원소를 추가할 공간이 생겼으니 기존의 배열을 지우고 원소를 추가하면 됩니다!

정말일까요? 이렇게 작동하는지 코드로도 한 번 확인해 볼까요?

>>> import sys
>>> a = []
>>> sys.getsizeof(a)
56
>>> id(a)
4384747712
>>> a.append(12345)
>>> sys.getsizeof(a) # append()이후 사이즈가 늘어남
88
>>> a.append(123456)
>>> sys.getsizeof(a)
88
>>> a.append(123457)
>>> sys.getsizeof(a)
88
>>> id(a[0])
4346585936 
>>> id(a[1])
4346585808
>>> a.append(1234568)
>>> sys.getsizeof(a) # 크기가 4가 될 때까지 크기는 그대로
88
>>> a.append(1234569) 
>>> sys.getsizeof(a) # 원소가 추가되면서 크기가 늘어남!
120
>>> id(a[0]) # 기존 원소의 메모리 주소는 그대로!
4346585936
>>> id(a) # a의 주소도 그대로!
4384747712

List의 원소 개수가 일정 크기가 되면 List의 크기가 증가하고, 기존 원소의 주소는 변하지 않는 것을 확인할 수 있습니다!

이제 “객체”에 대한 개념이 조금 명확해지셨나요?

“파이썬의 모든 객체는 PyObject 로 구성되어 있다!”라는 것이 지금까지의 핵심 내용이었습니다.

Q. 모든 것이 객체인 것을 알면 뭐가 좋나요?

조금 더 pythonic한 코드를 작성하실 수 있습니다. 아래 코드 중 무엇이 더 효율적인가요? 그 이유는요?

if A != True:
 print('A is not true')

if not A:
 print('A is not true')
  • 정답
    /* 1번 */
    PyObject *T = Py_True;
    Py_INCREF(T);
    PyObject *R = PyObject_Compare(A, T, Py_NE);
    Py_DECREF(T);
    if (PyObject_IsTrue(R)) {
        PyObject_Print(xxx);
    }
    Py_DECREF(R);
    
    ----
    /* 2번 */
    if (PyObject_Not(A)) {
        PyObject_Print(xxx);
    }

파이썬의 GC는 어떻게 동작하는가

앞서 나왔던 ob_refcnt 를 기억하실까요? 이 변수는 GC와 아주 관련이 깊습니다!

우리는 파이썬의 모든 객체가PyObject 로 구성되어 있다고 알게 되었습니다. 따라서 모든 객체는 ob_refcnt 를 가지고 있다고 볼 수 있겠죠! 그렇다면 ob_refcnt 로 해당 객체가 몇 번이나 참조되고 있는지 파악할 수 있지 않을까요? 그리고 더 이상 참조되지 않는다면 메모리를 해제해주면 되지 않을까요? 아래 예시를 보시죠.

'''
# 아래는 ob_refcnt를 +,- 해주는 함수

((PyObject *)(op))->ob_refcnt++;

# ob_refcnt가 0이면 할당 해제

PyObject *_py_decref_tmp = (PyObject *)(op);
if (--(_py_decref_tmp)->ob_refcnt == 0)
  _Py_Dealloc(_py_decref_tmp);

'''
import sys
>>> a = [1,2,3]
>>> sys.getrefcount(a)
2
>>> b = a
>>> sys.getrefcount(a)
3
>>> b = 1
>>> sys.getrefcount(a)
2

a = 3 # -> [1,2,3] refcount가 0이 되면서 메모리는 free

이렇게만 하면 문제가 없을까요? 곰곰이 생각해보면 2가지 문제가 떠오릅니다.

  1. ob_refcnt를 접근하는데 race condition이 발생한다면?
  2. 순환 참조가 발생한다면?
    • 순환 참조 예시
      container = [i for i in range(10**3)]
      container.append(container)
      sys.getrefcount(container)
      3
      del container
      # container의 ob_refcnt는 0이 아님!
      '''
      순환 참조를 발생 안 시킬 자신이 있다면 GC를 끄는 방법도 있습니다.
      대표적인 예시로 인스타가 있는데요
      
      자세한 내용은 아래 링크를!
      
      https://instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172#.koitdzt7n
      '''

1번의 경우 파이썬은 그 유명한 GIL(Global interpreter lock)을 사용하여 race condition을 방지하고 있습니다. 파이썬에서 GIL을 제거하기 힘든 이유가 납득이 됩니다.

2번은 순환 탐지 알고리즘을 이용하여 해결합니다! 이를 위해서 Pyobject 에 정보를 조금 추가합니다. 위 순환 참조 예시처럼 어떤 변수도 해당 객체에 접근하지 못하더라도 GC는 접근할 수 있어야 하니까요!

따라서 _PyObject_GC_Alloc 으로 생성되어 GC의 추적을 받는 Python 컨테이너 객체들은 PyGC_HEAD 를 추가로 가집니다. 그림으로 표현하면 아래와 같습니다.

Tip : 컨테이너 객체 : “여러 개의 다른 객체를 담을 수 있는 객체”를 뜻합니다. 예시로 사용자 class, list, tuple, dict 등이 있습니다. 컨테이너 객체가 아닌 경우 앞서 본 PyObject_VAR_HEAD 정보를 가집니다.

               +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ \
              |                    *_gc_next                  | |
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | PyGC_Head
              |                    *_gc_prev                  | |
object -----> +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ /
              |                    ob_refcnt                  | \
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | PyObject_HEAD
              |                    *ob_type                   | |
              +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ /
              |                      ...                      |

이제 _gc_next , _gc_prev 포인터를 통해 객체에 대한 접근이 가능해졌습니다.

파이썬의 GC는 자바의 그것과 마찬가지로 Generational Hypothesis를 전제로 설계되었습니다.

  • 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
  • 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

파이썬은 객체 관리 영역을 3단계 세대로 나누고 있습니다.

/* 
https://github.com/python/cpython/blob/3.10/Modules/gcmodule.c#L139 
3.11 부터는 코드가 다소 변경되었습니다.
*/
void
_PyGC_InitState(GCState *gcstate)
{
    gcstate->enabled = 1; /* automatic collection enabled? */

#define _GEN_HEAD(n) GEN_HEAD(gcstate, n)
    struct gc_generation generations[NUM_GENERATIONS] = {
        /* PyGC_Head,                                    threshold,    count */
        {{(uintptr_t)_GEN_HEAD(0), (uintptr_t)_GEN_HEAD(0)},   700,        0},
        {{(uintptr_t)_GEN_HEAD(1), (uintptr_t)_GEN_HEAD(1)},   10,         0},
        {{(uintptr_t)_GEN_HEAD(2), (uintptr_t)_GEN_HEAD(2)},   10,         0},
    };
    for (int i = 0; i < NUM_GENERATIONS; i++) {
        gcstate->generations[i] = generations[i];
    };
    gcstate->generation0 = GEN_HEAD(gcstate, 0);
    struct gc_generation permanent_generation = {
          {(uintptr_t)&gcstate->permanent_generation.head,
           (uintptr_t)&gcstate->permanent_generation.head}, 0, 0
    };
    gcstate->permanent_generation = permanent_generation;
}

여기서는 간단히 “Young 한 세대에 속한 객체일수록 더 자주 collect가 발생하며, 순환 탐지를 한다” 정도로만 이해하시면 됩니다. GC의 작동 방식도 아주 큰 주제이기 때문에 이는 다른 글에서 다루어 보도록 하겠습니다.

자세한 GC의 내용이 궁금하시면 여기여기를 참고하시면 큰 도움이 됩니다. (물론 레퍼런스 카운팅도 GC에 포함 됩니다.)


파이썬의 메모리 구조

자 지금까지 어떻게 파이썬에서 객체가 생성되어서 메모리에 올라가고 내려가는지 파악했습니다! 👏👏👏

그런데 파이썬에서 객체에 대한 메모리를 해제하면 바로 OS에게 해당 메모리가 반환될까요?

정말 마지막으로 이 의문만 살펴봅시다! 아래는 파이썬의 메모리 구조입니다.

상당히 추상화 되어있지요? 천천히 살펴봅시다.

    _____   ______   ______       ________
   [ int ] [ dict ] [ list ] ... [ string ]       Python core         |
+3 | <----- Object-specific memory -----> | <-- Non-object memory --> |
    _______________________________       |                           |
   [   Python's object allocator   ]      |                           |
+2 | ####### Object memory ####### | <------ Internal buffers ------> |
    ______________________________________________________________    |
   [          Python's raw memory allocator (PyMem_ API)          ]   |
+1 | <----- Python memory (under PyMem manager's control) ------> |   |
    __________________________________________________________________
   [    Underlying general-purpose allocator (ex: C library malloc)   ]
 0 | <------ Virtual memory allocated for the python process -------> |
   =========================================================================
    _______________________________________________________________________
   [                OS-specific Virtual Memory Manager (VMM)               ]
-1 | <--- Kernel dynamic storage allocation & management (page-based) ---> |
    __________________________________   __________________________________
   [                                  ] [                                  ]
-2 | <-- Physical memory: ROM/RAM --> | | <-- Secondary storage (swap) --> |

파이썬은 오버헤드를 줄이기 위해 512bytes 보다 작은 객체는 sub-allocator(이하 small object allocator)를 활용하고 이보다 더 큰 객체는 C allocator를 이용합니다. 대부분의 상황에서 큰 크기의 메모리 할당이 필요하지 않기 때문에 small object allocator가 사용됩니다.

small object allocator는 Arena, Pool , Block 3개의 계층으로 나뉩니다.

Block은 8 bytes 단위로 고정된 크기의 메모리 청크입니다.

 * Request in bytes     Size of allocated block      Size class idx
 * ----------------------------------------------------------------
 *        1-8                     8                       0
 *        9-16                   16                       1
 *       17-24                   24                       2
 *       25-32                   32                       3
 *       33-40                   40                       4
 *       41-48                   48                       5
 *       49-56                   56                       6
 *       57-64                   64                       7
 *       65-72                   72                       8
 *        ...                   ...                     ...
 *      497-504                 504                      62
 *      505-512                 512                      63
 *

Pool 은 같은 크기의 block을 모아둔 집합체입니다. 사이즈는 memory page와 동일한 4KB입니다. 따라서 크기가 512Bytes인 block이 모여있는 pool 같은 경우는 block을 8개까지, 더 작은 block 집합은 더 많은 block을 가질 수 있습니다.

구조체는 아래와 같습니다. 구조체와 주석을 보면 대략적인 정보와 어떻게 사용되는지 유추가 됩니다.

/* Pool for small blocks. */
struct pool_header {
    union { block *_padding;
            uint count; } ref;          /* number of allocated blocks    */
    block *freeblock;                   /* pool's free list head         */
    struct pool_header *nextpool;       /* next pool of this size class  */
    struct pool_header *prevpool;       /* previous pool       ""        */
    uint arenaindex;                    /* index into arenas of base adr */
    uint szidx;                         /* block size class index        */
    uint nextoffset;                    /* bytes to virgin block         */
    uint maxnextoffset;                 /* largest valid nextoffset      */
};

여기서 block은 3가지 상태를 가집니다.

  1. untouched : 할당이 안 된 메모리(OS가 아닌 파이썬에게)
  2. free : 할당되었다가 cpython으로 부터 free된 메모리
  3. allocated : 실제 할당된 메모리

Arena 는 가장 큰 단위로 pool 들이 모여있는 집합입니다.

여기에서 pool도 마찬가지로 used , full, empty 상태로 나뉩니다.

Arena 자체는 어떠한 상태를 가지지 않고 크기는 256kb입니다. 64개의 pool을 가질 수 있겠죠.

구조체는 아래와 같습니다.

struct arena_object {
    /* The address of the arena, as returned by malloc.  Note that 0
     * will never be returned by a successful malloc, and is used
     * here to mark an arena_object that doesn't correspond to an
     * allocated arena.
     */
    uintptr_t address;

    /* Pool-aligned pointer to the next pool to be carved off. */
    block* pool_address;

    /* The number of available pools in the arena:  free pools + never-
     * allocated pools.
     */
    uint nfreepools;

    /* The total number of pools in the arena, whether or not available. */
    uint ntotalpools;

    /* Singly-linked list of available pools. */
    struct pool_header* freepools;

    /* Whenever this arena_object is not associated with an allocated
     * arena, the nextarena member is used to link all unassociated
     * arena_objects in the singly-linked `unused_arena_objects` list.
     * The prevarena member is unused in this case.
     *
     * When this arena_object is associated with an allocated arena
     * with at least one available pool, both members are used in the
     * doubly-linked `usable_arenas` list, which is maintained in
     * increasing order of `nfreepools` values.
     *
     * Else this arena_object is associated with an allocated arena
     * all of whose pools are in use.  `nextarena` and `prevarena`
     * are both meaningless in this case.
     */
    struct arena_object* nextarena;
    struct arena_object* prevarena;
};

그렇다면 이것을 그림으로 표현해보면 어떻게 될까요?

아래는 usable_arenas를 표현해본 그림입니다.

arena, pool, block이 서로 이중 링크드 리스트를 사용해서 연결된 모습이겠죠!

물론 위 그림이 정확한 표현은 아닙니다. pool에도 free block에 대한 포인터가 있고, arena도 free pool에 대한 포인터가 있는 등 더 표현해야 할 것은 많겠죠.

보통 메모리 관리자는 공간이 가장 적게 남은 arena를 맨 앞으로 정렬시켜놓고, 새로운 데이터가 할당되는 경우, 사용할 공간이 가장 적게 남은 arena에 먼저 할당하도록 구성됩니다. 그 이유는 메모리 해제와 관련이 있습니다.

파이썬이 ‘진짜’ 메모리 할당을 해제하여 OS에 반환하는 단위는 arena이기 때문입니다! 모든 pool이 free인 arena만이 OS에 반납 됩니다. 그러면 여유 공간이 많은 arena는 pool과 block이 추가되는것 보다 free를 기다리는 것이 효율적이겠지요? 새로운 arena 할당 없이, arena 내에서 메모리를 block 단위로 할당하고 해제하는 경우는 파이썬의 추상화된 메모리 계층에서 진행이 됩니다. 작은 단위로 메모리를 할당, 해제하는 것은 시스템 콜 호출, 유저 스위칭 등 오버헤드가 많이 들기 때문에 파이썬은 이러한 관리 방식을 선택했습니다.

Cpython의 객체와 메모리 관리 방식에 살짝 맛을 보았는데 어떠셨나요? 재미있으셨나요?

더 깊게 공부할 내용은 참 많죠? 파이썬 메모리 계층에서 보셨듯이 memory allocator도 계층에 따라 3단계로 나뉩니다. 더 깊고 자세히 공부하고 싶으시면 여기를 참조해 보세요! 각 memory allocator와 arena의 관계를 공부해 보는 것도 좋겠죠? 이외에도 어떻게 Jython, pypy가 GIL을 극복하였는지도 재미있는 주제 같습니다.

긴 글 읽어주셔서 감사합니다. 잘못된 정보가 있다면 언제든 말씀해 주세요!

출처 :

https://github.com/python/cpython

http://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/

https://realpython.com/python-memory-management/

https://devguide.python.org/internals/garbage-collector/index.html

https://blog.hanlee.io/2018/under-the-c-2

그 외 본문에서 링크로 삽입함

그림 : 직접 그림

profile
내가 배운 것 정리

0개의 댓글