메모리 누수는 사라진 메모리가 아닙니다. 여전히 여러분 것이고 여전히 잡혀 있는데, 마지막 포인터가 사라져 돌려줄 능력을 잃은 메모리입니다. 아무것도 죽지 않습니다. 프로그램은 매번 조금씩 무거워지면서 계속 돌다가, 결국 상관없어 보이는 어딘가에서 무언가가 실패합니다.
이 페이지는 누수가 어떻게 생기는지, 대부분을 막는 규율, 그리고 나머지를 찾아 주는 두 도구를 다룹니다.
누수는 어떻게 생겼나
매 반복이 block을 새 포인터로 덮어씁니다. 이전 덩어리는 여전히 할당되어 있고, 어떤 변수도 그 주소를 담고 있지 않으며, 영원히 해제할 수 없습니다. 세 번 반복하면 12킬로바이트를 잃습니다. 요청마다 이 일을 하는 서버는 요청이 오는 속도로 영원히 잃습니다.
고치는 데는 한 줄 - 본문 끝의 free(block); - 이면 되지만, 어디에 속하는지 알아보는 것이 진짜 기술입니다.
누수가 생기는 방식
1. 잃어버린 포인터
살아 있는 덩어리를 가리키는 유일한 참조를 여전히 담고 있는 포인터에 대입하면 그 덩어리가 샙니다.
char *name = malloc(32);
name = malloc(64); /* 처음의 32바이트에는 이제 닿을 수 없다 */
위의 반복문은 같은 버그가 반복문 옷을 입은 것입니다. 구조체 필드를 다시 대입하는 것도 그렇고, calloc과 realloc의 realloc 축약도 그렇습니다.
p = realloc(p, n); /* 실패하면: p가 NULL이 되고 옛 덩어리는 고아가 된다 */
2. 이른 return
함수를 빠져나가는 모든 경로는 그 함수가 이미 가져간 것을 놓아줘야 합니다. 잊히는 것은 언제나 오류 경로입니다.
정상 경로는 맞고 오류 경로가 샙니다. 그래서 이것이 테스트를 통과합니다. 실패하는 갈래는 개발 중에 거의 실행되지 않으니까요. 해법은 모든 경로가 뛰어드는 정리 구역 하나입니다.
이것이 경험 있는 C 프로그래머들이 적극적으로 권하는 유일한 goto 용법입니다. 모든 포인터가 NULL에서 시작하고 free(NULL)이 아무 일도 하지 않으므로, 함수가 어디까지 갔든 단일 출구 블록이 올바릅니다.
3. 불분명한 소유권
가장 미묘한 누수는 코딩 오류가 전혀 아닙니다. 누구의 일이었는지에 대해 두 함수가 의견이 다른 것입니다.
char *build_message(void); /* 호출자가 이걸 해제하나? */
void store(char *text); /* store가 소유권을 가져가나? */
build_message가 할당된 메모리를 반환하고 store가 그것을 복사한다면 호출자가 해제해야 합니다. store가 포인터를 보관한다면 호출자는 해제하면 안 됩니다. 코드의 어떤 것도 어느 쪽인지 말해 주지 않으므로, 두 가정 중 하나가 두 번 내려지고 그 결과 누수이거나 이중 해제가 됩니다.
해법은 할당하는 모든 함수 옆 주석에 밝혀 두는 관례입니다.
/* 새로 할당한 문자열을 반환한다. 호출자가 해제해야 한다. */
char *build_message(void);
/* 'text'의 소유권을 가져간다. store_free()가 해제할 것이다. */
void store(char *text);
규칙을 설계 문서가 아니라 함수 옆에 쓰세요. C 메모리 관리에서 가치가 가장 높은 단 하나의 습관입니다.
소유권 규율
규칙 넷이 거의 전부를 덮습니다.
- 모든 할당에는 정확히 한 명의 소유자가 있습니다. 그것을 해제할 책임이 있는 코드 하나입니다.
- 할당 함수마다 해제 함수를 짝지으세요.
vec_init/vec_free,config_load/config_free처럼요. 대칭이 빠진 호출을 눈에 띄게 합니다. - 할당한 것과 같은 계층에서 해제하세요. 함수 주석이 명시적으로 소유권을 넘기는 경우가 아니라면요.
- 해제한 뒤에는 포인터를
NULL로 두세요. 나중에 실수로 쓰면 힙을 조용히 망가뜨리는 대신 사고 지점에서 죽습니다.
누수 찾기: valgrind
리눅스에서 valgrind는 다시 컴파일할 필요가 없지만, 디버그 심볼이 있으면 보고가 읽기 쉬워집니다.
gcc -g -O0 program.c -o program
valgrind --leak-check=full --show-leak-kinds=all ./program
이 페이지 맨 위의 새는 반복문에 대해 보고는 이런 식으로 끝납니다.
==12345== HEAP SUMMARY:
==12345== in use at exit: 12,000 bytes in 3 blocks
==12345== total heap usage: 3 allocs, 0 frees, 12,000 bytes allocated
==12345==
==12345== 12,000 bytes in 3 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2FB0F: malloc (vg_replace_malloc.c:299)
==12345== by 0x108671: main (program.c:6)
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 12,000 bytes in 3 blocks
아래에서부터 읽으세요. "definitely lost"는 종료 시점에 그 덩어리를 가리키는 포인터가 없었다는 뜻이고, 진짜 누수입니다. 스택 추적은 그것이 사라진 줄이 아니라 그것을 만든 malloc의 줄을 알려 주는데, 보통 빠진 free를 찾기에는 충분합니다.
다른 범주도 둘 나옵니다.
- indirectly lost - 그 자체가 사라진 덩어리를 통해서만 닿을 수 있는 덩어리입니다. 누수된 연결 리스트의 원소들 같은 것이죠. "definitely lost"인 것을 고치면 이것들도 사라집니다.
- still reachable - 종료 시점에 할당되어 있지만 살아 있는 포인터가 있는 것으로, 보통 전역 캐시입니다. 위험한 의미의 누수는 아니지만, 보고를 비워 두기 위해 해제할 만합니다.
valgrind는 초기화되지 않은 메모리 읽기와 덩어리 끝을 넘어선 쓰기도 잡아내는데, 종종 이런 식으로 누수 뒤에 있는 버그를 발견하게 됩니다.
누수 찾기: AddressSanitizer
AddressSanitizer는 GCC와 Clang에 내장되어 있고, valgrind보다 훨씬 빠르며, valgrind가 안 되는 곳(현재의 macOS 포함)에서도 동작합니다.
gcc -g -fsanitize=address -fno-omit-frame-pointer program.c -o program
./program
누수 보고가 종료 시 자동으로 출력됩니다.
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 12000 byte(s) in 3 object(s) allocated from:
#0 0x7f... in malloc
#1 0x1086... in main program.c:6
SUMMARY: AddressSanitizer: 12000 byte(s) leaked in 3 allocation(s).
ASan은 해제 후 사용과 힙 버퍼 오버플로도 나중의 알 수 없는 손상 대신 즉각적이고 분명히 표시된 중단으로 바꿔 줍니다. 테스트 실행은 이것을 켜서 빌드하고, 릴리스는 꺼서 빌드하세요. 메모리와 속도를 소모하니까요.
플랫폼에서 누수 탐지가 켜지지 않으면, 실행 전 환경에 ASAN_OPTIONS=detect_leaks=1을 설정하세요.
새는 프로그램을 단계별로 고치기
서로 다른 누수 셋이 있는 작은 프로그램입니다.
valgrind는 서로 다른 줄 번호로 "definitely lost" 기록 셋을 보고합니다. 하나씩 고쳐 봅시다.
수정 1은 소유권 주석을 실제로 구현한 것입니다. shout이 할당하고 main이 해제합니다. 수정 2는 첫 것을 해제하는 대신 중복된 할당을 아예 없앱니다. 더 단순한 코드가 올바른 코드이기도 합니다. 수정 3은 이른 return에 빠진 free를 넣습니다. 할당이 더 많이 얽히면 앞서 보여 준 단일 cleanup: 레이블이 해제를 반복하는 것보다 잘 확장됩니다.
누수를 막는 습관
malloc을 쓴 즉시free를 쓰고, 그다음 사이의 코드를 채우세요.- 할당하는 모든 함수에 짝이 되는 해제 함수를 주세요.
- 자신이 할당한 포인터를 반환하거나 받는 함수에는 소유권을 주석으로 밝히세요.
- 여러 할당을 들고 있는 함수에는
cleanup:출구 블록 하나를 쓰세요. - 무언가 이상해 보일 때만이 아니라, 당연한 일로
-fsanitize=address아래에서 테스트를 돌리세요. - "definitely lost: 0 bytes"를 통과하는 테스트의 일부로 여기세요.
자주 묻는 질문
C에서 메모리 누수란 무엇인가요?
malloc으로 할당했지만 프로그램의 어떤 것도 더 이상 가리키지 않아 해제할 수 없게 된 메모리입니다. 그 덩어리는 프로세스가 사는 내내 잡혀 있습니다. 크래시가 아닙니다. 프로그램은 계속 동작하면서 매번 메모리를 더 쓰다가 결국 바닥납니다.
C에서 메모리 누수는 어떻게 찾나요?
valgrind 아래에서 프로그램을 실행하세요. valgrind --leak-check=full ./program입니다. 종료 시점에 여전히 할당되어 있는 모든 덩어리를, 그것을 만든 malloc의 스택 추적과 함께 보고합니다. macOS나 valgrind를 쓸 수 없는 곳에서는 -fsanitize=address로 컴파일하면 종료 시 같은 보고가 나옵니다.
C에서 메모리 누수의 원인은 무엇인가요?
세 가지 패턴이 거의 전부를 덮습니다. 어떤 덩어리를 가리키는 유일한 포인터를 덮어쓰기(실패 시의 p = realloc(p, n) 포함), 이미 할당한 함수에서 이르게 return 하기, 그리고 불분명한 소유권입니다. 두 함수가 서로 상대가 해제할 거라고 가정해 결국 아무도 하지 않는 것이죠.
어차피 프로그램이 끝나는데 메모리 누수가 중요한가요?
한 번 돌고 끝나는 프로그램이라면 운영체제가 전부 회수하므로 실질적인 영향은 없습니다. 중요한 것은 오래 도는 것들 - 서버, 게임 루프, 데몬 - 인데, 요청마다 새면 끝없이 자랍니다. 그래도 일관되게 해제하세요. 누수 보고는 잡음이 되어 정작 중요한 것을 가립니다.