공유할수록 더 느려지는 풀(Pool)
요약
ArrayPool<byte>.Shared 사용 시 발생할 수 있는 숨겨진 경합(contention) 문제를 LLM 기반의 dotnet-diagnostics-mcp 도구를 통해 해결하는 과정을 다룹니다. 정적 분석으로는 찾기 어려운 런타임 동작 문제를 실시간 대화형 디버깅으로 추적하는 사례를 보여줍니다.
핵심 포인트
- ArrayPool<byte>.Shared는 특정 패턴에서 Monitor.Enter_Slowpath 경합을 유발할 수 있음
- 정적 분석기가 감지하지 못하는 런타임 성능 회귀 문제를 LLM으로 해결
- dotnet-diagnostics-mcp를 활용한 실시간 프로세스 진단 및 디버깅 가능
LLM과의 15분간의 대화가 어떻게 정적 분석기(static analyzer)가 절대 찾아낼 수 없는 숨겨진 ArrayPool<byte>.Shared 경합(contention) 지점을 찾아냈는지 — 그리고 왜 그 해결책이 락(lock)을 추가하는 것과는 전혀 상관이 없었는지에 대하여.
티켓 (The ticket)
"지난 스프린트에서 브로드캐스트 팬아웃(broadcast fan-out) 경로를 추가한 후
market-data-gateway의 CPU 사용량이 약 20% 상승했습니다. 새로운 할당(allocation)도 없고, GC 압박(GC pressure)도 없으며, 변경 사항(diff)에서 명확하게 잘못된 부분도 없습니다. 포드(pod)를 더 확장하기 전에 누군가 확인해 줄 수 있나요?"
동기화 기본 요소(synchronization primitive)처럼 보이는 코드는 아무도 건드리지 않았습니다.
전주에 배포된 변경 사항은 버퍼 풀링(buffer-pooling) 헬퍼를 수정한 것뿐이었습니다. 자체 제작한 바이트 배열 캐시를 "분명히 옳은" BCL 기본값인 ArrayPool<byte>.Shared로 교체한 것이었죠. 해당 리팩터링(refactor)은 커스텀 코드를 제거하고 잘 알려져 있으며 고도로 최적화된 프레임워크 타입으로 대체했다는 이유로 특별히 검토를 거쳐 병합되었습니다. 무엇보다도, 이는 당연히 이득이 되는 작업(free win)이어야 했습니다.
이것은 정적 분석(static analysis)이 구조적으로 인지하지 못하는 종류의 회귀(regression)입니다. 어떤 분석 규칙도 "여러 스레드에 걸쳐 ArrayPool<T>.Shared를 사용하는 것이 의심스럽다"라고 말하지 않습니다. 이것은 .NET에서 버퍼를 풀링하는 데 권장되는 방식입니다. 문제는 변경 사항(diff)에서 읽을 수 있는 코드에 있는 것이 아니라, 특정하고 놓치기 쉬운 사용 패턴 하에서만 나타나는 런타임 동작(runtime behavior)에 있습니다.
실제 조사 과정
이것이 dotnet-diagnostics-mcp의 진정한 가치 제안입니다. 다음에 이어지는 과정 중 그 어떤 것도 디버거(debugger)를 부착하거나, 계측(instrumentation)을 추가하거나, 무엇인가를 재배포할 필요가 없었습니다. 이는 실행 중인 프로세스에 MCP 도구가 연결된 LLM과의 실시간 대화입니다.
1단계 — 상황 파악 (orient.)
user> pid 41213에서 무슨 일이 일어나고 있는지 확인해 줄 수 있나요? CPU가 높아 보입니다.
assistant> 먼저 프로세스를 살펴보겠습니다.
...
{
"processId": 41213,
"resolvedProcess": { "name": "market-data-gateway", "runtime": "net10.0" },
...
"누수(leak)"를 외치는 것도, "GC"를 외치는 것도 없습니다. CPU는 그저... 바쁩니다. 어디서 그러는지 확인해 볼 시간입니다.
Step 2 — sample.
assistant> 10초 동안의 CPU 샘플을 수집합니다.
→ collect_sample(kind="cpu", processId=41213, durationSeconds=10, topN=20)
{
"processId": 41213,
"totalSamples": 18545,
...
이것이 첫 번째 진짜 놀라운 사실입니다. 모든 CPU 샘플의 15.8%가 Monitor.Enter_Slowpath 내부에서 발견되었습니다. 이 서비스의 그 누구도 lock 문을 작성하지 않았습니다. 전체 저장소(repo)에서 grep -r "lock ("를 실행해도 관련 있는 결과가 나오지 않습니다. 그렇다면 경합(contended)이 발생하는 모니터는 어디에서 오는 걸까요?
Step 3 — 락(lock)을 호출 지점(call site)까지 추적합니다.
assistant> 이 코드베이스에는 명시적인 락이 없습니다. 누가 모니터 슬로우 패스(monitor slow path)를 호출하고 있는지 확인해 보겠습니다.
→ query_snapshot(handle="A0CKKWH5VVS9Q68AH3GG", view="caller-callee",
...
{
"method": "System.Threading.Monitor.Enter_Slowpath(System.Object)",
"exclusiveSamples": 2933,
...
두 호출자 모두 우리의 코드입니다. 한쪽은 .Rent()를 호출하는 얇은 래퍼(wrapper) 메서드이고, 다른 한쪽은 .Return()을 호출하는 연결별 소비 루프(per-connection consume loop)입니다. ThreadPool 내부도 아니고, GC 때문도 아닙니다. 락은 ArrayPool<byte>.Shared 자체 내부에서 획득되고 있으며, 경합이 발생하는 것은 비즈니스 로직이 아니라 바로 *풀(pool)*입니다.
Step 4 — 락에 대해 추측하지 말고, 실제 락을 읽으십시오.
ArrayPool<byte>.Shared의 내부 구현은 System.Private.CoreLib에 있습니다. 프라이빗 구현 타입(private implementation type)에 대한 소스 코드나 PDB가 없습니다. 이것이 바로 동반 서버인 dotnet-assembly-mcp가 필요한 이유입니다. 이 도구는 BCL 자체를 포함하여 컴파일된 어셈블리(assembly)의 정적 탐색 및 온디맨드 역컴파일(decompilation)을 지원합니다.
assistant> 추측하지 않겠습니다. 현재 실행 중인 런타임 바이너리에서 실제 풀 구현을 찾아보겠습니다.
→ find_method(mvidOrPath="System.Private.CoreLib.dll", namePattern="^Return$")
{
"summary": "/^Return$/에 대해 5개의 일치 항목이 있습니다.",
"matches": [
...
assistant> 이 정확한 런타임 빌드에서 실제 Return 메서드를 디컴파일합니다.
→ decompile_method(moduleVersionId="e8c78f6b-...", metadataToken="0x060038A7")
public override void Return(T[] array, bool clearArray = false)
{
// ...
...
assistant> 그리고 코어별 파티션으로 푸시하는 폴백 경로입니다:
→ find_method(mvidOrPath="System.Private.CoreLib.dll", namePattern="^TryPush$")
→ decompile_method(moduleVersionId="e8c78f6b-...", metadataToken="0x060038B3")
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryPush(Array array)
{
...
바로 여기입니다 — Monitor.Enter(this), 살아있는 형태로, 이 프로세스가 실행 중인 정확한 바이너리에서: ArrayPool<byte>.Shared를 뒷받침하는 실제 내부 타입은 SharedArrayPool<T>이며, 그 코어별 폴백은 일반적인 CLR 모니터에 의해 보호됩니다. 이것이 call-tree가 Monitor.Enter_Slowpath를 정확히 가리킨 이유입니다: 이는 실제로 존재하며 평범한 모니터로, Return 호출이 개별 스레드 캐시를 건너뛰지 못할 때마다 모든 스레드가 경쟁하는 대상입니다.
분명히 하자면: dotnet/runtime의 소스 코드는 GitHub에 공개되어 있습니다 — 여기에는 아무것도 숨겨진 것이 없습니다. 요점은 소스가 도달 불가능했다는 것이 아니라, 쿼리의 사용 편의성(ergonomics) 문제입니다.
// 단 하나의 전용 브로드캐스터 스레드 — 렌트(Rent)를 수행하는 유일한 스레드.
while (running)
{
...
도구가 출력한 결과가 암시했던 바로 그 패턴입니다: 하나의 스레드가 렌트(rent)하고, 다른 많은 스레드들이 반환(return)합니다.
실시간 증거 없이는 이것이 보이지 않는 이유
ArrayPool<T>.Shared는 단순한 공유 가방이 아닙니다. 현재의 .NET 10 구현체인 SharedArrayPool<T>는 스레드당 하나의 슬롯 캐시([ThreadStatic] 버킷)를 유지합니다. 만약 버퍼를 렌트(rent)했던 것과 동일한 스레드가 이를 반환한다면, 해당 작업은 단순한 필드 스왑(field swap)이며 동기화(synchronization)가 전혀 발생하지 않습니다. 하지만 다른 스레드가 이를 반환하는 순간, 그 빠른 경로(fast path)를 적용할 수 없게 됩니다(해당 슬롯은 다른 스레드에 속해 있기 때문). 따라서 버퍼는 **각각 일반 Monitor로 보호되는 소수의 코어별 파티션(per-core partitions)**으로 넘어가게 됩니다. 소수의 코어와 수십 개의 소비자 스레드가 모두 동시에 반환을 수행하면, 이 몇 안 되는 파티션들은 진정한 경합 지점(contention point)이 됩니다.
이 중 그 어떤 것도 ArrayPool<T>의 버그가 아닙니다. 이는 설계된 대로 정확히 작동하고 있는 것입니다. 즉, 일반적인 케이스(예: 단일 요청 핸들러 내부에서 한 스레드가 렌트-사용-반환을 수행하는 경우)에 최적화되어 있으며, 스레드 간 팬아웃(cross-thread fan-out) 케이스에 대해서는 성능이 우아하게 저하되지만, 조용히 공짜로 처리되지는 않도록 설계되었습니다. "당신은 직접 작성하지 않은 BCL 타입의 암시적인 성능 가정을 위반하려 하고 있습니다"라고 알려주는 컴파일러 경고도, 분석기(analyzer) 규칙도, 코드 리뷰 휴리스틱도 없습니다. 이 프로세스가, 이 런타임 빌드에서 실제로 무엇을 하고 있는지 확실히 알 수 있는 유일한 방법은 CPU가 어디로 향하는지 관찰하고 실행 중인 정확한 바이너리를 읽는 것뿐입니다.
수정 및 검증
해결책은 "락(lock)을 추가하는 것"이 아닙니다(보호해야 할 대상이 없습니다). 실제 소유권 패턴, 즉 특정 누구도 렌트하지 않은 항목을 많은 스레드가 반환하는 패턴에 맞게 풀(pool) 구현체를 교체하는 것입니다. 즉, 단순한 락 프리 큐(lock-free queue)로 바꾸는 것입니다.
public sealed class CrossThreadBufferPool
{
private readonly ConcurrentQueue<byte[]> _pool = new();
...
격리 비용, 전/후 비교 (BenchmarkDotNet, 20,000회의 스레드 간 Return 호출, 측정 영역 외부의 백그라운드 스레드에서 버퍼를 미리 대여(pre-rented)하여 오직 Return 자체만 측정됨):
| 시나리오 | 평균 시간 (20,000회 호출) | 경합이 있는 기준점(baseline) 대비 |
|---|---|---|
ArrayPool.Shared.Return, 스레드 간 (버그 발생) | ~4.3–5.1 ms | 1x (기준점) |
| ... |
마지막 행은 상한선(ceiling)입니다. 스레드별 캐시 슬롯(per-thread cache slot)이 항상 적중하기 때문에 동기화(synchronization)가 진정으로 0인 유일한 시나리오입니다. 이를 ConcurrentQueue 수정안과 비교하면 우리가 만든 수정안이 여전히 얼마나 많은 오버헤드(overhead)를 안고 있는지 정확히 알 수 있습니다: ~126 µs 대 ~115 µs — 이론적인 최상치보다 대략 10% 높음. 이는 임의의 수의 서로 관련 없는 스레드들이 안전하게 사용할 수 있도록 설계된 자료 구조임을 감안할 때 그렇습니다. 이것이
당연히 뒤따르는 질문: 여기서 풀링(Pooling)을 할 가치가 있기는 한가?
ArrayPool<T>.Shared 자체는 단순히 new byte[size]를 호출하고 GC(Garbage Collector)가 이를 회수하도록 두는 단순한(naive) 베이스라인에 대한 최적화로서 존재합니다. 위에서 언급한 모든 내용에 대해 타당한 반론이 있을 수 있습니다. 만약 공유(shared) 풀이 스레드 간 Return 시 이 정도로 경합(contention)이 발생한다면, 다른 풀로 교체하는 것이 아니라 아예 풀링을 포기하는 것이 정직한 해결책일지도 모릅니다. 일반적인 할당(allocation)은 어떤 종류의 동기화(synchronization)도 필요하지 않으므로 평균 지연 시간(mean latency)은 오히려 경쟁력이 있어 보일 수도 있습니다. 하지만 기대되는 바는 Gen0/Gen1 GC 압박이 평균이 아닌 꼬리 지연 시간(tail latency, P99)에서 나타나야 한다는 것입니다.
우리는 이를 테스트했습니다. 동일한 2-스레드 생산자/소비자(producer/consumer) 토폴로지를 사용하되, 풀 구현체만 Rent 시마다 새로 할당하고 Return 시에는 아무것도 하지 않는 방식으로 교체했습니다.
| 시나리오 | 처리량 (Throughput) | Monitor 경합 |
|---|---|---|
ArrayPool.Shared, 스레드 간 교차 | ~3.2M ops/s | 15.8% (Monitor) |
| ... |
풀링을 하지 않는 것은 단순히 꼬리 지연 시간(tail-latency)만 악화되는 회귀(regression)가 아닙니다. 이는 순수 처리량(raw throughput) 측면에서 약 18배 더 느리며, 우리가 시작점으로 삼았던 경합이 발생하는 ArrayPool.Shared 베이스라인보다도 더 나쁩니다. 그 이유는 동일한 시간 범위 동안의 실시간 GC 일시 중단(GC pause) 캡처에서 즉시 드러납니다.
assistant> 풀을 완전히 제거했을 때 GC에 어떤 영향을 미치는지 확인해 봅시다.
→ collect_events(kind="gc", processId=41213, durationSeconds=10)
{
"summary": "134 collection(s), max pause 69.9ms, total pause 5118.1ms."
}
assistant> 이를 세분화하면:
→ query_snapshot(handle="...", view="pauseHistogram")
{
"totalCollections": 134,
"maxPauseTime": "00:00:00.0698743",
...
assistant> 그리고 세대(generation)별로 보면:
→ query_snapshot(handle="...", view="byGeneration")
{
"generations": [
{ "bucket": "gen1", "count": 134, "totalPause": "00:00:05.1180759",
...
10초의 시간 동안 134개의 gen1 컬렉션 발생 — 초당 13.4회, 모든 컬렉션이 10~100ms 버킷(bucket)에 해당하며 — 이 10초 동안 누적된 일시 중단(pause) 시간은 5.12초에 달합니다. 실제 경과 시간(wall clock)의 절반 이상이 애플리케이션 처리량(throughput)이 아닌 GC 일시 중단(GC pause) 시간입니다. 이는 단순히 꼬리 지연 시간(tail-latency)에 관한 각주 수준의 문제가 아닙니다. 할당량(allocation volume)이 충분히 높아져서 4KB 버퍼를 약 130ms 주기로 gen0에서 승격(promote) 및 수집(collect)될 만큼 빠르게 밀어넣게 되면, 이는 평균(mean) 지연 시간까지 지배하게 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기