스레드를 늘렸는데 처리량이 비례해서 오르지 않고, 프로파일을 떠보면 _int_malloc이나 __lll_lock_wait 같은 함수가 상위에 올라오는 경우가 있다. glibc의 malloc은 아레나(arena) 단위로 락을 잡기 때문에, 짧은 객체를 자주 할당·해제하는 코드에서는 이 락이 병목이 된다. tcmalloc은 스레드마다 로컬 캐시를 두어 대부분의 할당에서 락을 아예 건드리지 않는 할당자로, 코드를 고치지 않고 LD_PRELOAD 한 줄로 바꿔 끼울 수 있다. 이 글에서는 Ubuntu 24.04에서 tcmalloc을 적용하는 방법과, 같은 벤치마크로 glibc와 비교한 처리량·메모리 사용량, 그리고 잘못 튜닝했을 때 오히려 느려지는 사례를 정리한다.
설치와 라이브러리 선택
sudo apt install -y libgoogle-perftools-dev google-perftools
ls /usr/lib/x86_64-linux-gnu/libtcmalloc*.so.4/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc_and_profiler.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc_debug.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4
| 라이브러리 | 포함 기능 | 쓰는 곳 |
|---|---|---|
libtcmalloc_minimal | 할당자만 | 성능만 원할 때 (권장 기본값) |
libtcmalloc | 할당자 + 힙 프로파일러 + 힙 체커 | 메모리 사용처를 함께 보고 싶을 때 |
libtcmalloc_and_profiler | 위 + CPU 프로파일러 | gperftools 프로파일링까지 할 때 |
libtcmalloc_debug | 경계 검사 등 디버그 빌드 | 힙 오염 추적 |
적용 방법
기존 바이너리를 그대로 두고 바꿔 끼우려면 LD_PRELOAD면 된다. 실제로 로드됐는지는 LD_DEBUG=libs로 확인한다.
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 ./myapp
# 정말 적용됐는지 확인
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 LD_DEBUG=libs ./myapp 2>&1 >/dev/null | grep tcmalloc34102: calling init: /usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4
34102: calling fini: /usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 [0]
빌드 단계에서 링크할 수도 있다. 이때는 컴파일러가 malloc/free 호출을 자체 최적화로 치환하지 않도록 -fno-builtin-*를 함께 준다.
gcc -O2 -fno-builtin-malloc -fno-builtin-calloc \
-fno-builtin-realloc -fno-builtin-free \
-o myapp myapp.c -lpthread -ltcmalloc_minimal
ldd myapp | grep tcmalloclibtcmalloc_minimal.so.4 => /lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 (0x000076229f642000)
측정용 벤치마크
스레드마다 16B~4KB 크기의 블록을 무작위로 할당·해제하며 실제로 첫 바이트를 건드리는 워크로드다. 처리량과 함께 최대 RSS를 남겨 메모리 트레이드오프도 같이 본다.
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define SLOTS 128
static int nthreads, iterations;
static void *worker(void *arg)
{
unsigned seed = (unsigned)(size_t)arg;
void *slot[SLOTS] = {0};
for (int i = 0; i < iterations; i++) {
int idx = rand_r(&seed) % SLOTS;
size_t sz = 16 + rand_r(&seed) % 4080; /* 16B ~ 4KB */
free(slot[idx]);
slot[idx] = malloc(sz);
if (slot[idx])
((char *)slot[idx])[0] = (char)i; /* 실제로 페이지를 건드린다 */
}
for (int i = 0; i < SLOTS; i++)
free(slot[i]);
return NULL;
}
static long rss_kb(void)
{
FILE *f = fopen("/proc/self/status", "r");
char line[256];
long kb = 0;
while (f && fgets(line, sizeof(line), f))
if (sscanf(line, "VmHWM: %ld kB", &kb) == 1)
break;
if (f) fclose(f);
return kb;
}
int main(int argc, char **argv)
{
nthreads = argc > 1 ? atoi(argv[1]) : 4;
iterations = argc > 2 ? atoi(argv[2]) : 2000000;
pthread_t th[64];
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (long i = 0; i < nthreads; i++)
pthread_create(&th[i], NULL, worker, (void *)(i + 1));
for (int i = 0; i < nthreads; i++)
pthread_join(th[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
double sec = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
printf("threads=%2d elapsed=%6.3fs %8.2f Mops/s peak RSS=%ld KB\n",
nthreads, sec, nthreads * (double)iterations / sec / 1e6, rss_kb());
return 0;
}glibc와 비교
6코어 장비에서 스레드당 1000만 회씩 돌린 결과다.
for n in 1 4 12; do ./mallocbench $n 10000000; done
for n in 1 4 12; do LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 ./mallocbench $n 10000000; done| 할당자 | 스레드 | 경과 시간 | 처리량 | 최대 RSS |
|---|---|---|---|---|
| glibc | 1 | 0.573초 | 17.47 Mops/s | 2,276 KB |
| glibc | 4 | 0.691초 | 57.91 Mops/s | 3,804 KB |
| glibc | 12 | 1.637초 | 73.28 Mops/s | 8,164 KB |
| tcmalloc_minimal | 1 | 0.136초 | 73.37 Mops/s | 8,844 KB |
| tcmalloc_minimal | 4 | 0.162초 | 247.37 Mops/s | 11,488 KB |
| tcmalloc_minimal | 12 | 0.382초 | 314.07 Mops/s | 18,372 KB |
| tcmalloc (풀버전) | 1 | 0.129초 | 77.68 Mops/s | 9,568 KB |
| tcmalloc (풀버전) | 4 | 0.161초 | 248.94 Mops/s | 12,180 KB |
| tcmalloc (풀버전) | 12 | 0.461초 | 260.34 Mops/s | 18,556 KB |
스레드가 하나일 때도 4.2배 빠르다. 즉 이 차이는 락 경합만이 아니라 할당 경로 자체의 길이에서도 온다. 대신 최대 RSS는 12스레드에서 8.1MB → 18.4MB로 2.2배 늘었다 — 스레드 캐시가 메모리를 미리 쥐고 있기 때문이며, 속도를 메모리로 사는 구조다.
glibc가 느린 이유 확인
glibc는 스레드가 락 경합을 만나면 아레나를 늘려 회피한다. MALLOC_ARENA_MAX로 그 수를 강제로 줄이면 경합이 그대로 드러난다.
for a in 1 4 8; do MALLOC_ARENA_MAX=$a ./mallocbench 12 10000000; doneMALLOC_ARENA_MAX=1 : threads=12 elapsed=39.666s 3.03 Mops/s peak RSS=7976 KB
MALLOC_ARENA_MAX=4 : threads=12 elapsed=11.845s 10.13 Mops/s peak RSS=8036 KB
MALLOC_ARENA_MAX=8 : threads=12 elapsed= 1.942s 61.79 Mops/s peak RSS=6148 KB
(제한 없음) : threads=12 elapsed= 1.637s 73.28 Mops/s peak RSS=8164 KB
아레나 하나에 12스레드를 몰아넣으면 3.03 Mops/s로 24배 느려진다. 아레나를 늘리는 것이 곧 락을 쪼개는 것이고, tcmalloc은 이 구조를 스레드 로컬 캐시로 한 단계 더 밀어붙인 셈이다.
스레드 캐시 크기 튜닝
TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES는 모든 스레드 캐시의 합계 상한이다. 현재 값은 MallocExtension으로 직접 물어볼 수 있다.
#include <gperftools/malloc_extension.h>
#include <cstdio>
int main()
{
size_t v = 0;
const char *keys[] = {
"tcmalloc.max_total_thread_cache_bytes",
"tcmalloc.current_total_thread_cache_bytes",
"tcmalloc.pageheap_free_bytes",
};
for (auto k : keys)
if (MallocExtension::instance()->GetNumericProperty(k, &v))
printf("%-45s = %zu (%.1f MB)\n", k, v, v / 1048576.0);
return 0;
}$ g++ -O2 -o tcprops tcprops.cc -ltcmalloc_minimal && ./tcprops
tcmalloc.max_total_thread_cache_bytes = 33554432 (32.0 MB)
tcmalloc.current_total_thread_cache_bytes = 8 (0.0 MB)
tcmalloc.pageheap_free_bytes = 950272 (0.9 MB)
이 환경의 기본 상한은 32MB다. 메모리를 아끼겠다고 이 값을 줄이면 캐시 미스가 중앙 힙 락으로 몰린다.
for sz in 1048576 16777216 67108864; do
LD_PRELOAD=$TCM TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES=$sz ./mallocbench 12 10000000
done| 캐시 상한 | 경과 시간 | 처리량 | 최대 RSS |
|---|---|---|---|
| 1 MB | 9.754초 | 12.30 Mops/s | 16,480 KB |
| 16 MB | 0.394초 | 304.25 Mops/s | 18,232 KB |
| 32 MB (기본값) | 0.382초 | 314.07 Mops/s | 18,372 KB |
| 64 MB | 0.342초 | 350.81 Mops/s | 16,172 KB |
1MB로 조이면 glibc(73.28 Mops/s)보다도 6배 느려진다. 반대로 64MB로 키우면 12% 더 빨라지면서 RSS는 오히려 줄었다 — 캐시가 넉넉하면 중앙 힙과 주고받는 횟수가 줄어 단편화가 덜하기 때문이다. 값을 바꿀 때는 반드시 실제 워크로드로 재봐야 한다.
| 환경 변수 | 이 환경의 값 | 효과 |
|---|---|---|
TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES | 32MB | 스레드 캐시 총량 상한 |
TCMALLOC_RELEASE_RATE | 1.0 | 해제한 메모리를 OS에 돌려주는 속도 (0이면 안 돌려줌) |
TCMALLOC_SAMPLE_PARAMETER | 미설정 | 힙 샘플링 간격(바이트). 설정해야 샘플링이 켜진다 |
HEAPPROFILE | – | 지정하면 힙 프로파일을 주기적으로 덤프 |
힙 프로파일 보기
풀버전 libtcmalloc에는 힙 프로파일러가 들어 있다. HEAPPROFILE에 경로를 주면 실행 중 프로파일을 덤프하고, google-pprof로 읽는다.
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 \
HEAPPROFILE=/tmp/hp ./mallocbench 4 3000000
google-pprof --text ./mallocbench /tmp/hp.0001.heapDumping heap profile to /tmp/hp.0023.heap (Exiting, 5 kB in use)
threads= 4 elapsed=28.820s 0.42 Mops/s peak RSS=13884 KB
Total: 1.0 MB
1.0 99.9% 99.9% 1.0 99.9% worker
0.0 0.1% 100.0% 0.0 0.1% allocate_dtv
0.0 0.0% 100.0% 1.0 99.9% clone3
0.0 0.0% 100.0% 0.0 0.1% main
0.0 0.0% 100.0% 1.0 99.9% start_thread
할당의 99.9%가 worker에서 나온 것이 바로 잡힌다. 대신 같은 워크로드가 248 Mops/s에서 0.42 Mops/s로 떨어졌다 — 프로파일러는 모든 할당의 콜스택을 뜨므로, 운영 중인 서비스에 그냥 켜면 안 된다.
주의사항
- 메모리를 더 쓴다: 위 측정에서 RSS가 2.2배 늘었다. 컨테이너 메모리 제한이 빡빡한 환경에서는
TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES와 함께 한도를 다시 잡아야 한다. - 튜닝은 양날: 캐시를 1MB로 줄이면 glibc보다 6배 느려진다. 기본값에서 시작해 실측으로만 움직인다.
- glibc도 요즘은 빠르다: glibc 2.26의 tcache 도입 이후 단일 스레드 격차는 크게 줄었다. 여기서 4배가 난 것은 16B~4KB를 초당 수천만 번 할당하는 극단적 워크로드이기 때문으로, 할당 빈도가 낮은 애플리케이션에서는 차이가 거의 없다.
- valgrind와 함께 못 쓴다: valgrind는 자체 할당자를 끼우므로 tcmalloc과 충돌한다. 메모리 오류를 잡을 때는
LD_PRELOAD를 빼고 돌린다. - 정적 링크 시 순서 주의:
-ltcmalloc은 링크 라인 뒤쪽에 두고-fno-builtin-*를 빠뜨리지 않는다. 빠뜨리면 컴파일러가 인라인한 할당 경로가 glibc로 남아 두 할당자가 섞인다. - fork 안전성: 멀티스레드 상태에서
fork()후exec()없이 할당을 계속하는 코드는 할당자 종류와 무관하게 위험하다. tcmalloc으로 바꿨더니 재현 빈도만 달라지는 경우가 있다.
마무리
tcmalloc은 코드를 한 줄도 안 고치고 LD_PRELOAD만으로 시험해 볼 수 있는 몇 안 되는 성능 개선책이다. 할당이 잦은 멀티스레드 워크로드라면 이 글의 측정처럼 4배 이상 차이가 날 수 있지만, 그만큼 메모리를 더 쓰고 캐시 설정을 잘못 잡으면 glibc보다 느려지기도 한다. 도입 전에 자기 워크로드로 처리량과 RSS를 함께 재보고, 원인이 정말 할당자인지는 프로파일로 먼저 확인하는 순서가 안전하다.