PHP의 변수 관리법

PHP 의 메모리 할당은 어떻게 이루어질까?

Posted by PAPION on November 12, 2019 · 10 mins read #PHP

PHP의 변수 관리

이 포스팅은 원본의 번역 & 정리본입니다.

최근 메모리 누수가 발생하여 PHP의 메모리구조에 대해 알아보았다.
우선 PHP의 변수는 C 구조인 zval 이라는 구조체에 정의된다.

struct _zval_struct {
    zvalue_value value;			/* value */
    zend_uint refcount __gc;
    zend_uchar type;			/* active type */
    zend_uchar is_ref __gc;
};

zvalue_value value 는 실질적인 값이다. (an integer, a string, and object…)
메모리 관리는 refcount, is_ref 2개의 필드와 연관이 있다.

refcount 는 얼마나 많은 변수들이 이 zval을 가르키는지 나타내는 integer이다. 예를 들어 $a = 'foo' 즉 변수의 값(foo)은 위의 zval 구조체로 메모리에 저장되고, 각 변수($a)zval 을 가르키게 되는데, 이 때 얼마나 많은 변수들이 가르키고 있는지 알 수 있다.

is_ref boolean 을 나타내는 정수이다. default는 0이고, 아직 참조되지 않은 상태를 의미한다.(&$a in PHP syntax)
1로 된다면 참조되고 있다는 것을 의미하고, 이 zval을 어떻게 가지고 노느냐에 따라 PHP는 크게 변화할 것이다.

Composite types

이 스크립트에서 몇개의 zval이 사용되고 있을까?
$a = 'foo';
물론 1개이다. 그림으로 나타낸다면 아래와 같다.

다음 스크립트를 보자.
$a = array('foo'=>'bar', 42);
여기에는 3개가 사용된다. string bar, integer 42, 마지막으로 이를 감싸는 array 이다. 그림은 아래와 같다.

여기서 배열의 키인 foo1zval이 아니다. 단순히 value 이다. 다음 objects 를 보자

class Foo {
	public $a = 42;
	protected $bar = 'default';
}
$obj = new Foo;

여기서도 3개이다. $obj 그리고 각각의 변수들($a$bar)
objectarray와 같이 동작한다. arrayobects 모두 스스로 1개의 zval 을 먹고 포함하는 각 변수들마다 zval을 먹는다.

PHP의 변수 관리 방법

PHP가 변수를 복사할 때 어떻게 zval memory를 복제하지 않고 어떻게 처리하는지 알아보자.

$a = 'foo';
$b = $a;


회색 부분은 변수이며 실제로 메모리를 소비하지 않는다. 메모리를 잡아먹는 부분은 노란색 zval 컨테이너이다. 복제작업으로 인해 해당 zval을 가르키는 변수가 증가하였고, 그에 따라 refcount 가 증가했을 뿐이다. 따라서 아래의 두 코드의 메모리 사용량은 동일하다.

// code1
$ a = ‘foo’;
$ b = $a;

// code2
$a = ‘foo’;

하나의 변수를 더 사용하여 낭비하는것처럼 보이지만, 사실은 refcount 만 증가할 뿐이다.

Copy on write

Copy On Write (줄여서 COW)는 메모리 절약을 위한 속임수이다. 소프트웨어 엔지니어링에서 많이 사용되고, 기존 zval의 변수 외에 새로운 값을 쓸 때 실제 메모리가 할당된다. 아래의 코드와 그림을 통해 이해해보자.

$a = "foo";
$b = $a;
$a = 17;


다른 예제도 있다.

$a = "foo";
$b = $a;
$c = $b;
$b = "bar";
unset($a);


refcount 가 어떻게 사용되는지 알 수 있다. unset 은 메모리를 감소시키는 것이 아니라 refcount를 하나 줄일 뿐이다.
줄이고 난 후 refcount가 0이 된다면 PHP는 그제서야 메모리를 해제한다. refcountis_ref의 값을 확인하려면 Xdebug extension의 xdebug_debug_zval()를 사용하여 확인할 수 있다.

마지막 예제를 살펴보자.

$a = array("foo"=>"bar", 1 => 42);
$b = $a["foo"];
$c = $b;
$b = 18;
unset($a['foo']);
$a[1] = $b;


동일한 동작임을 확인할 수 있다. zval은 0이 되기 이전에는 해제되지 않는다.

Functions (methods)

우선 함수와 메소드는 동일하다.
기억해야 할 것은 함수가 생성될때 각 변수들의 가용 범위라는 것이 같이 생성 된다. 예를들어 전역변수를 제외하고 함수내에서 생성된 변수들은 외부에서 접근할 수 없다.
함수의 매개변수와 리턴 값은 각각 refcount 가 증가하게끔 되어 있다. 콜스택이 동작하는 동안, 매개변수와 리턴 값에 해당하는 zvalrefcount을 올리는 것이다. 또 함수의 매개변수에는 PHP에서 제공하는 func_get_arg() 혹은 비슷한 기능을 하는 다른 함수로 접근할 수 있다. 기억해야 할 점은 PHP 변수만 zval 구조체를 사용하는 것이 아니다. PHP 내부 엔진 또한 동작하면서 얼마든지 사용할 수 있다. 그럴 경우 refcount를 올리는 것.
PHP가 함수를 벗어나면 그 스택과 매개변수를 파괴한다. 우리가 사용하는 unset과 같다. refcount가 감소하고 이전과 동일하게 적용된다. 즉 0이되면 메모리를 해제한다. 이는 자동적이며, 프로그래머는 메모리할당과 해제를 모두 할필요가 없다.

References

우선 참조란 PHP에서 & 부호를 사용할때 발생한다.

$a = 'string';
$b = &$a;
$b = 1;

$a$b는 모두 같은 value 를 공유하고 있다. 위와 같이 참조를 사용하는 것은 $a$b는 같은 zval에 묶는것을 의미한다. 사실 $a = $b;$a = &$b;는 이 단일 라인에서는 동일한 작업이 맞다. 하지만 마지막 라인의 $b = 1;에서 Copy On Write는 PHP를 크게 변화시킨다.

먼저 &를 사용하여 is_ref가 1로 증가한 것을 볼 수 있다. 하지만 refcount의 의미는 바뀌지 않는다.

하지만 is_ref는 COW가 발생할 때 PHP의 동작을 바꾼다. PHP는 변수들을 더이상 분리하지 않고, 두개의 변수를 묶은 것처럼 동작한다. 간단하고 효율적이지만 주의해서 사용해야한다. 다음 예제를 잘 살펴봐야한다.

$a = "string";
$b = &$a;
$c = $b;


$c = $b; 를 주의 깊게 보자.

여기서 PHP는 새로운 메모리를 할당하고 string을 복사한다. 이미 참조되었기(ref_count = 1) 때문이다. 참조변수가 아니였다면 refcount가 3인 zval이었을 것이다.
이제는 $c를 변경하더라도 $b가 변경되는지 않는다. $c$b를 참조하지 않기 때문이다. 참조를 사용할때 우리는 PHP가 예측하지 않는 곳에서 메모리를 복제하는 것을 조심해야한다.

다음 예제를 보자.

function foo(&$var)
{
    if (strlen($var) > 3) {
        return $var;
    } else {
        $var .= '_uppercased';
        return strtoupper($var);
    }
}

$value = 'barbaz';
echo foo($value);

아래 설명은 PHP 5.5이하의 내용이 포함되어 있다.
foo() 함수의 $var는 참조변수로 넘겨졌다. foo() 안에서 $varis_ref는 1일 것이다. 여기서 호출한 stlen()strtoupper()에서 어떤 동작이 벌어질까? 이 함수들은 value에 의해 동작하게 되고(매뉴얼에서 확인가능) $var은 참조에 바인딩되며, PHP는 그러한 string 관련 함수를 호출 할 때마다 메모리를 복제한다.

~~만약 strlen() 등 함수들이 새롭게 할당하여 쓰지않고 참조를 사용한다면, 저 함수들은 우리가 제어하는 변수값들을 변경할 수 있게 될 것이다. 우리는 그것을 원하지않고, PHP는 정확히 같은 값을 가진 새로운 값을 만들기 위해 $var zval을 복제해서 사용해야만 한다. ~~

foo()에 참조변수를 넘기지 않았다면, 중복은 전혀 일어나지 않았을 것이다. 요즘 barbaz와 같은 string을 복제하는데에는 nanoseconds가 소요된다. 하지만 배열의 경우는 다르다. 특히 많은 키와 복잡한 배열의 경우.. 배열자체가 복사되므로 매우 많은 리소스가 소요될 수 있다.(PHP 5.5를 실행하는 2013 년 데스크톱 하드웨어에서는 백만 개의 슬롯 배열을 복제하는 데 약 0.3 초 소요)

Memory leak, gc는 다음 포스트에서 작성한다.

더 테스트해볼것. 3개의 참조일 때 동작, 실제 메모리 사용확인

실제 메모리 사용 확인

function foo(&$var)
{
	xdebug_debug_zval('var');        ---> 1번

    if (strlen($var) > 3){
		xdebug_debug_zval('var');    ---> 2번
        return $var;
    } else {
        $var .= '_uppercased';
        return strtoupper($var);
    }
}
$value = 'barbaz';
echo foo($value);

현재 5.6.1 버전에서 위 소스로 테스트 해보았다. foo() 함수의 인자로 $var&$var 로 비교해 보았으나 최종 메모리 사용량은 같았다. 그리고 xdebug_debug_zval() 1, 2번의 결과는 (refcount=3, is_ref=1),string 'barbar' (length=6) 와 같았다. 위에서 언급한 내용은 5.5 버전 이전의 내용인 것으로 추측된다, 지금 테스트한 5.6.1 에서는 strlen()이나 strtoupper()로 인해 메모리가 추가로 사용되거나 refcount 가 높아지는 현상은 없었다.

결국은 참조변수를 복사하는데만 조심한다면 메모리는 추가로 사용되지 않을 것이다.

참조변수 테스트

1 $a = "varvar";
2 $b = &$a; or $b = $a;
3 $c = &$b;
4 xdebug_debug_zval('a');
5 xdebug_debug_zval('b');
6 xdebug_debug_zval('c');

위의 경우 결과물은 아래와 같다. 중요한 점은 2번라인까지는 메모리 추가사용이 없었으나 3번부터 그 이후의 참조는 계속해서 refcount가 같은 zval이 생성되는지 메모리가 계속해서 복사되었다.

a:(refcount=3, is_ref=1),string 'varvar' (length=6)
-> 872 bytes
b:(refcount=3, is_ref=1),string 'varvar' (length=6)\
-> 872 bytes
c:(refcount=3, is_ref=1),string 'varvar' (length=6)
1032 bytes