파이썬 메모리 관리과 효율적인 코드

‍한승운·2021년 3월 1일
7

python

목록 보기
6/13

파이썬의 메모리 관리

  • 파이썬의 좋은 점은 파이썬의 모든 것이 객체라는 것

    = 동적 메모리 할당이 파이썬 메모리 관리의 기초

    • 객체가 더 이상 필요하지 않으면 Python 메모리 관리자가 자동으로 객체에서 메모리를 회수
  • Python 메모리 관리자는 Python의 메모리 할당을 관리

    • 모든 파이썬 객체와 데이터 구조를 포함하는 개인 힙이 있습니다.
    • Python 메모리 관리자에는 객체 별 할당자가있어 int, string 등과 같은 특정 객체에 대해 메모리를 명확하게 할당
    • 그 아래에서 원시 메모리 할당자는 운영 체제의 메모리 관리자와 상호 작용하여 개인 힙에 공간이 있는지 확인
  • Python 메모리 관리자는 "블록"이라는 메모리 청크를 관리합니다.

  • 동일한 크기의 블록 모음이 "풀"을 구성

  • 풀은 힙 = 64 풀에 할당 된 256kB 메모리 덩어리 인 Arena에서 생성

  • 객체가 파손되면 메모리 관리자는이 공간을 동일한 크기의 새 객체로 채 웁니다.

  • 메소드와 변수는 스택 메모리에 작성

    • 메소드와 변수가 작성 될 때마다 스택 프레임이 작성됩니다.
    • 이러한 프레임은 메소드가 리턴 될 때마다 자동으로 제거됩니다.
  • 오브젝트 및 인스턴스 변수는 힙 메모리에 작성

  • 변수와 함수가 반환 되 자마자 죽은 개체는 가비지 수집됩니다.

  • Python 메모리 관리자가 반드시 메모리를 운영 체제로 다시 릴리스 할 필요는 없으며 대신 메모리가 Python 인터프리터로 다시 리턴됩니다.

  • 파이썬에는 작은 객체 할당자가있어 추후 사용을 위해 메모리를 할당합니다.

  • 장기 실행 프로세스에서 사용되지 않는 메모리의 증분 예약이있을 수 있습니다.

  • 파이썬은 C 또는 C++과 같이 프로그래머가 직접 메모리를 관리하지 않고 레퍼런스 카운트(Reference Counts)와 가비지 콜렉션(Automatic Garbage Collection)에 의해 관리

레퍼런스 카운트(Reference Counts)

  • 파이썬은 내부적으로 malloc()와 free()를 많이 사용하기 때문에 메모리 누수의 위험이 있습니다.

  • 이런 이슈가 있기 때문에 파이썬은 메모리를 관리하기 위한 전략으로 레퍼런스 카운트를 사용합니다.

  • 레퍼런스 카운트 전략이란 파이썬의 모든 객체에 카운트를 포함하고, 이 카운트는 객체가 참조될 때 증가하고, 참조가 삭제될 때 감소시키는 방식으로 작동됩니다. 이때 카운터가 0이 되면 메모리가 할당이 삭제됩니다.

  • Cpython 코드로 보는 내부 동작

    • PyObject(파이썬 객체)

      • ob_refcnt(레퍼런스 카운트)
    • Py_INCREF : 레퍼런스 카운트 증가

    • Py_DECREF : 레퍼런스 카운트 감소

      • _Py_Dealloc(op) : 레퍼런스 카운트가 0 이 되었을 경우 메모리 할당 삭제
      /* 파이썬의 객체 형태 */
      typedef struct _object {
          _PyObject_HEAD_EXTRA
          Py_ssize_t ob_refcnt;               /* 레퍼런스 카운트 */
          struct _typeobject *ob_type;
      } PyObject;
      
      
      
      /* ob_refcnt를 증가시킵니다. */
      static inline void _Py_INCREF(PyObject *op)
      {
          _Py_INC_REFTOTAL;
          op->ob_refcnt++;
      }
      
      /* ob_refcnt 0일때 _Py Dealloc(op)을 사용하여 메모리 할당을 제거합니다. */
      static inline void _Py_DECREF(const char *filename, int lineno,
                                    PyObject *op)
      {
          (void)filename; /* may be unused, shut up -Wunused-parameter */
          (void)lineno; /* may be unused, shut up -Wunused-parameter */
          _Py_DEC_REFTOTAL;
          if (--op->ob_refcnt != 0) {
      #ifdef Py_REF_DEBUG
              if (op->ob_refcnt < 0) {
                  _Py_NegativeRefcount(filename, lineno, op);
              }
      #endif
          }
          else {
              _Py_Dealloc(op);
          }
      }
  • sys 라이브러리의 getrefcount()를 통해 파라미터로 전달된 객체의 레퍼런스 카운트 확인할 수 있습니다.

    import sys
    
    class RefExam():
      def __init__(self):
        print('create object')
    
    a = RefExam()
    print(f'count {sys.getrefcount(a)}')
    b = a
    print(f'count {sys.getrefcount(a)}')
    c = a
    print(f'count {sys.getrefcount(a)}')
    c = 0
    print(f'count {sys.getrefcount(a)}')
    b = 0
    print(f'count {sys.getrefcount(a)}')
    
    """
    OUT PUT:
    count 2 # 여기서 2가 출력되는 이유는 getrefcount()의 파라미터값으로 임시 참조되기 때문에 예상과 다르게 1이 아닌 2가 출력
    count 3
    count 4
    count 3
    count 2
    """

레퍼런스 카운트의 약점 순환참조

  • 순환 참조란 간단하게 컨테이너 객체가 자기 자신을 참조하는 것을 말합니다. 자기 자신이 참조될 때 프로그래머는 할당된 객체를 추적하기 어려워지고, 이때 메모리 누수가 발생할 수 있습니다. 아래 예제 코드를 보겠습니다.
class RefExam():
  def __init__(self):
    print('create object')
  def __del__(self): # 메모리 할당이 삭제되는 시점에서 실행되는 메서드
    print(f'destroy {id(self)}')


a = RefExam()
a = 0
print('end .....')

"""
OUT PUT:
create object
destroy 3112733520336
end .....
"""
# a 변수에 0을 재할당할 떄 __del__이 실행되고 마무리 하는 것을 볼 수 있음
# me 프로퍼티에 자기 자신을 할당합니다.
class RefExam():
  def __init__(self):
    print('create object')
    self.me = self
  def __del__(self):
    print(f'destroy {id(self)}')

a = RefExam()
a = 0
print('end .....')

"""
OUT PUT:
create object
end .....
destroy 2110595412432
"""
# ‘end …..’를 출력하고 __del__()이 실행되는 걸 확인 
# a 변수에 새로운 값을 할당해도 a.me 프로퍼티에 자기 자신을 참조하고 있어 레퍼런스 카운트가 남아있기 때문에 이런 현상이 발생
# 이렇게 되면 레퍼런스 카운트가 0에 도달할 수 없고 할당된 메모리를 삭제할 수 없어 메모리 누수가 발생
# 파이썬은 이 문제를 가비지 콜렉션으로 해결합니다.

가비지 콜렉션(Automatic Garbage Collection)

  • 설명에 들어가기에 앞서 알아두셔야 하는 것은 레퍼런스 카운트도 가비지 콜렉션이(GC)라고 부릅니다. 이를 구분하기 위해서 순환 참조 이슈를 해결하기 위해 구현한 가비지 콜렉션을 ‘Automatic garbage collection’이라고 부릅니다.(Python Doc 1.10.Reference Counts)
  • 파이썬에서는 Cyclic Garbage Collection을 지원합니다. 이는 순환 참조 이슈를 해결하기 위해 존재하며, 참조 주기를 감지하여 메모리 누수를 예방합니다.
  • Generational Hypothesis - 가비지 콜렉션의 가설기반
    • 대부분의 객체는 생성되고 오래 살아남지 못하고 곧바로 버려지는 것
    • 젊은 객체가 오래된 객체를 참조하는 상황은 드물다
    • 이를 통해 유추할 수 있는 것
      • 메모리에 존재하는 객체를 오래된 객체(old)와 젊은 객체(young)로 나눌 수 있는데, 대부분의 객체는 생성되고 곧바로 버려지기 때문에 젊은 객체가 비교적 더 많이 존재
      • 젊은 객체에 대부분의 객체가 존재하니, 가비지 컬렉터가 작동 빈도수를 높여 젊은 객체 위주로 관리해주는 것

세대관리

  • 파이썬은 객체 관리를 위한 영역을 3가지로 나뉨. 이 영역을 세대(generation)라고 함

  • 파이썬은 세대를 초기화 할 때 _PyGC_Intialize 메소드 호출 (https://github.com/python/cpython/blob/bf8162c8c45338470bbe487c8769bba20bde66c2/Modules/gcmodule.c#L129)

    • 코드를 초기화할 때 임계 값(threshold)을 각 700, 10, 10으로 초기화하고 카운트(count)를 0, 0, 0으로 초기화
    • 이를 python 런타임 환경에서는 gc.get_threshold()와 gc.get_count() 를 통해 가능
  • 임계값 활용 방법

    • 객체가 생성될 때 0세대의 카운트 값이 증가
    • 증가될 때 0세대의 카운트와 임계값을 비교하여 만약 카운트가 임계 값보다 클 때 쓰레기 수집을 실행하고 0세대는 초기화
    • 0세대의 살아남은 객체는 다음 1세대로 옮겨지고 1세대의 카운트(count)는 1 증가합니다. 이런 방식으로 젊은 세대(young)에서 임계 값이 초과하면 오래된 세대(old)로 위임하는 방식으로 3세대 영역으로 관리
    #define NUM_GENERATIONS 3                 /* 3세대로 관리 */
    
    // ...
    
    #define GEN_HEAD(state, n) (&(state)->generations[n].head)
    
    // ...
    
    void
    _PyGC_Initialize(struct _gc_runtime_state *state)
    {
        state->enabled = 1; /* automatic collection enabled? */
    
      #define _GEN_HEAD(n) GEN_HEAD(state, n)
        struct gc_generation generations[NUM_GENERATIONS] = {
            /* PyGC_Head,                                           threshold,    count */
            \{\{(uintptr_t)_GEN_HEAD(0), (uintptr_t)_GEN_HEAD(0)\},   700,        0\},      /** 0세대 초기화 */
            \{\{(uintptr_t)_GEN_HEAD(1), (uintptr_t)_GEN_HEAD(1)\},   10,         0\},      /** 1세대 초기화 */
            \{\{(uintptr_t)_GEN_HEAD(2), (uintptr_t)_GEN_HEAD(2)\},   10,         0\},      /** 2세대 초기화 */
        };
        for (int i = 0; i < NUM_GENERATIONS; i++) {
            state->generations[i] = generations[i];
        };
        
      // ...
    }
    import gc
    print(gc.get_threshold())
    print(gc.get_count())
    """
    OUTPUT:
    (700, 10, 10)
    (18, 7, 8)              // 현재 count상태를 확인하는 것이기 때문에 출력값이 다를 수 있다.

좀더 자세히 들여다보기

  • 파이썬은 객체가 생성될 때 _PyObject_GC_Alloc 메소드를 호출
    • generations[0].count (0세대)를 증가
    • 이후 현재 상태를 확인(우측 주석 확인)하여 조건에 충족하지 않을 때 collect_generations을 호출

static PyObject *
_PyObject_GC_Alloc(int use_calloc, size_t basicsize)
{
    struct _gc_runtime_state *state = &_PyRuntime.gc;
    PyObject *op;
    PyGC_Head *g;
    size_t size;
    if (basicsize > PY_SSIZE_T_MAX - sizeof(PyGC_Head))                         /* 메모리 할당 */
        return PyErr_NoMemory();
    size = sizeof(PyGC_Head) + basicsize;
    if (use_calloc)
        g = (PyGC_Head *)PyObject_Calloc(1, size);
    else
        g = (PyGC_Head *)PyObject_Malloc(size);
    if (g == NULL)
        return PyErr_NoMemory();
    assert(((uintptr_t)g & 3) == 0);  // g must be aligned 4bytes boundary
    g->_gc_next = 0;
    g->_gc_prev = 0;
    state->generations[0].count++; /* number of allocated GC objects */         /* 0세대 증가 */
    if (state->generations[0].count > state->generations[0].threshold &&        /* 임계값 비교 */
        state->enabled &&                                                       /* 사용여부 */
        state->generations[0].threshold &&                                      /* 임계값 설정 여부 */
        !state->collecting &&                                                   /* 수집중 여부 */
        !PyErr_Occurred()) {

        state->collecting = 1;                                                  /* 수집 상태 활성화 */
        collect_generations(state);                                             /* 모든 세대 검사 메소드 */
        state->collecting = 0;
    }
    op = FROM_GC(g);
    return op;
}
  • 임계값 검사

    • 이는 가비지 컬렉션 성능 향상을 위한 전략으로 새로 생성된 객체(long_lived_pending)의 수가 기존의 살아남았던 객체(long_lived_total)의 25%를 기준으로 기준치를 초과했을 때 전체 콜렉션이 실행됩니다. 자세한 내용은 pycore_pymem.h문서의 NOTE 주석을 통해 확인할 수 있습니다.

    • 조건문 조건에 만족하는 세대를 collect_with_callback 호출에 파라미터값으로 전달합니다.

    • collect_with_callback 함수에서 GC의 핵심인 collect를 호출합니다. collect는 내부에서 콜렉션 대상 이하의 세대 카운트를 초기화하고, 도달 가능(reachable)한 객체와 도달할 수 없는(unreachable) 객체를 분류합니다. 그리고 분류된 도달할 수 없는 객체들을 메모리에서 삭제

    • 이 과정은 먼저 레퍼런스 카운트(RC)를 gc_refs에 복사합니다. 그리고 객체에서 참조하고 있는 다른 컨테이너 객체를 찾아 참조되고 있는 컨테이너 객체의 gc_refs를 감소시킵니다. (TIP. 순환 참조는 컨테이너 객체에서 발생할 수 있는 이슈입니다)

    • 즉, 다른 컨테이너 객체에 참조되고 있는 수 A와 현재 레퍼런스 카운트 B를 빼서 B - A > 0 일 경우 도달 가능한 객체(reachable)가 되고, 0 일 때 도달할 수 없는 객체(unreachable)로 분류합니다.

      이후 도달 가능한 객체들은 다음 세대 리스트와 병합되고, 도달할 수 없는 객체들은 메모리에서 제거됩니다. 이런 메커니즘을 순환 참조 알고리즘 이라고 합니다

static Py_ssize_t
collect_generations(struct _gc_runtime_state *state)
{
    Py_ssize_t n = 0;
    for (int i = NUM_GENERATIONS-1; i >= 0; i--) {                            /** 마지막 세대부터 확인 */
        if (state->generations[i].count > state->generations[i].threshold) {
            if (i == NUM_GENERATIONS - 1
                && state->long_lived_pending < state->long_lived_total / 4)   /** 새 객체 수가 기존 객체 수의 25%를 초과하면 전체 콜렉션 실행 */
                continue;
            n = collect_with_callback(state, i);
            break;
        }
    }
    return n;
}
/** ==================*/
static Py_ssize_t
collect_with_callback(struct _gc_runtime_state *state, int generation)
{
    assert(!PyErr_Occurred());
    Py_ssize_t result, collected, uncollectable;
    invoke_gc_callback(state, "start", generation, 0, 0);
    result = collect(state, generation, &collected, &uncollectable, 0);
    invoke_gc_callback(state, "stop", generation, collected, uncollectable);
    assert(!PyErr_Occurred());
    return result;
}

효율적인 파이썬 코드의 모범사례

리스트 원소를 출력할 때 join 하기

# 안좋은예: + 로 string 잇기
mymsg=[‘line1’,’line2’] 
for msg in mymsg :
	word += msg+ ' '

# 더 나은 선택
mymsg=[‘line1’,’line2’] 
‘ ’.join(mymsg)

문자열에 + 연산자 피하기

# 안좋은 예
msg = ’hello’ + mymsg + ’world’

# 더 나은 선택
msg = f'hello {mymsg} world'

제네레이터(Generaotr) 사용하기

생성기를 사용하면 한 번에 모든 항목이 아닌 한 번에 하나의 항목을 반환하는 함수를 만들 수 있습니다. 즉, 데이터 집합이 큰 경우 전체 데이터 집합에 액세스 할 때까지 기다릴 필요가 없습니다.

class 이터레이터이름:
    def __init__(self, items) : # 반복할 자료
        self.items = items
    
    def __iter__(self) : # iterator 생성
        return self._generator()

    def _generator(self) :
        for itm in self.items() :
            yield itm
    

지역 변수에 함수 할당하기

파이썬은 전역 변수보다 훨씬 효율적으로 지역 변수에 액세스합니다. 지역 변수에 함수를 할당 한 다음 사용하십시오.

myLocalFunc = myObj.func
for i in range(n) :
    myLocalFunc(i)

내장 함수와 라이브러리 사용하기

# 안좋은 예
mylist =[]
for myword in oldlist :
    mylist.append(myword.upper())
    
# 더 나은 방법
mylist = map(str.lower, oldlist)

itertools를 사용하여 원치 않는 루프 제거

# 안좋은 예
mylist= []
for shape in [True, False] :
    for weight in (1,5) :
        firstlist += function(shape, weight)
        
# 더 나은 방법
from itertools import product, chain
list(chain.from_iterable(function(shape, weight) for weight, shape in product([True, False], range(1, 5))))

안전 및 메모리 관리를 위해 new 덮어 쓰기 및 메타 클래스 활용

  • __new__를 덮어 쓰고 메타 클래스를 활용하면 Singleton 및 Flyweight 패턴을 적용 할 때 메모리 관리에 유용하고 안전합니다.
  • 예를 들어 다음은 Yaml 파일을 읽는 dict 객체의 예입니다.
    • 메타 클래스는 일단 정의되면 싱글 톤 디자인 패턴이므로 시스템의 어느 곳으로나 가져 와서 다시 정의 할 수 있으며 인터프리터는 초기 객체 만 가리 킵니다.
    • 메모리 공간을 줄이고 안전을 보장합니다. 팀의 다른 개발자가 아무리 주니어에 있더라도 중복 된 객체는 발생하지 않으므로 시스템의 한 부분에서 dict를 변경하지 못하게하고 다른 부분에서 다른 dict를 참조하지 않습니다.
class Singleton(type) :
    _instances = {}
    def __call__(cls, *argsm **kwargs) :
        if cls not in cls._instances :
            cls.instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
            return cls._instances[cls]
        
class ConfigDict(dict, metaclass=Singleton) :
    def __init__(self) :
        super().__init__(self,read_config_file())
    
    @staticmethod
    def read_config_file() :
        config_file_path = sys.argv[-1]
        if not config_file_path.endwith(".yml") :
            raise ConfigDictError(message=f"yml file not passed into falsk app but {config_file_path} instead")
        return yaml.load(open(str(config_file_path)), Loader=yaml.FullLoader)   

파이썬에서 코드 성능 확인하는 법

  • cProfile 및 프로파일과 같은 프로파일링 모듈 사용

    python -m cProfile [-o output_file][-s sort_order](-m module | myscript.py)
profile
함께 성장하고 싶은 백엔드 개발자

0개의 댓글