TL;DR — 고정 블록 메모리 풀이
new/delete보다 효율적일 것이라는 예상은 이번 실험에서 성립하지 않았습니다. 단일 스레드에서는 두 방식이 비슷했고, 여러 스레드가 중앙 free-list를 공유하자 lock 경합으로 메모리 풀 처리량이 감소했습니다. 반면 역할별 용량으로 메모리 상한을 정하고 I/O context와 payload의 lifetime을 표현하는 가치는 명확했습니다.
Table of contents
Open Table of contents
들어가며
IOCP 서버의 OVERLAPPED context와 packet payload를 관리하기 위해 고정 블록 메모리 풀을 구현했습니다. 처음에는 반복적인 new/delete를 없애면 할당 속도도 자연스럽게 빨라질 것으로 예상했습니다.
이 예상이 맞는지 확인하기 위해 실제 서버와 분리된 콘솔 프로젝트에 간소화된 메모리 풀을 만들고 다음 구현을 같은 workload로 비교했습니다.
new/deletestd::mutex기반 고정 블록 메모리 풀- exponential pause를 적용한 spin lock 기반 고정 블록 메모리 풀
결과는 예상보다 복잡했습니다. 단일 스레드의 mutex 메모리 풀은 new/delete와 비슷했지만, 스레드가 늘어나자 하나의 mutex를 공유하는 메모리 풀의 처리량은 감소했습니다.
이 글에서는 이 결과를 기반으로 다음 내용을 정리합니다.
- C++의
new/delete와 Windows heap이 실제로 맡는 역할 - 실제 서버 구현에서 핵심만 분리한 고정 블록 메모리 풀
- I/O context와 shared payload의 lifetime을 메모리 풀 블록에 연결하는 방법
- 전체 용량 및 lock contention 실험 구성과 결과
- 멀티 스레드 환경에서 고성능 메모리 풀을 만들기 위해 필요한 구조
범위: 실험은 실제 IOCP 서버가 아니라 별도 콘솔 프로그램에서 수행한 microbenchmark입니다. 실제 서버 객체의 lifecycle과 traffic을 그대로 재현하지는 않습니다.
1. 메모리 풀이 필요했던 세 가지 이유
프로젝트에서 메모리 풀을 도입한 이유는 세 가지였습니다.
1.1 Pending I/O와 payload의 lifetime을 소유한다
WSARecv 또는 WSASend가 pending 상태가 되면 함수를 호출한 scope가 끝나도 OVERLAPPED context와 buffer는 completion까지 살아 있어야 합니다.
이 lifetime은 std::unique_ptr 같은 다른 RAII 도구로도 관리할 수 있습니다. 따라서 lifetime 관리만으로 메모리 풀이 반드시 필요한 것은 아닙니다. 프로젝트에서는 move-only 메모리 풀 블록과 context lease를 결합해 다음 ownership을 표현했습니다.
Acquire 블록
-> context 생성
-> I/O post
-> pending 동안 lease가 소유
-> completion 처리
-> context 파괴
-> 블록 반환
1.2 역할별 메모리 상한을 정한다
범용 allocator만 사용하면 각 worker가 만드는 객체 수를 별도 정책으로 제한해야 합니다. 고정 블록 메모리 풀에서는 역할별 블록 크기와 용량을 초기화할 때 정합니다.
role storage bytes = aligned 블록 stride × 용량
예를 들어 OverlappedContext의 stride가 128바이트이고 용량이 2,048개라면 해당 메모리 풀 storage는 256KiB입니다. 메모리 풀이 담당하는 경로는 이 크기보다 자동으로 확장되지 않습니다.
용량이 부족하면 새로운 heap 블록을 계속 만드는 대신 PoolExhausted를 반환합니다. 이를 통해 런타임의 추가 메모리 생성을 제한하고, 서버가 감당할 수 있는 동시 작업 수를 admission 또는 pressure 정책으로 연결할 수 있습니다.
이 상한은 프로세스 전체 메모리의 상한은 아닙니다. 스레드 stack, DLL, STL container, 일반 heap 객체와 kernel 자원은 별도로 존재합니다.
1.3 재사용과 사용량을 관측한다
동일한 크기의 블록을 재사용하면 메모리 풀 내부에서 서로 다른 크기의 allocation이 만든 빈 공간을 다룰 필요가 없습니다. 대신 alignment로 인해 블록 내부에 사용하지 않는 공간이 생길 수 있습니다(내부 단편화).
메모리 풀은 다음 값도 직접 기록할 수 있습니다.
- 현재 사용 중인 블록 수
- 사용 가능한 블록 수
- high-water mark
- acquire 실패 횟수
이 값으로 예상 용량과 실제 peak 사용량의 차이를 확인하고 다음 설정을 조정할 수 있습니다.
2. new/delete는 매번 OS를 호출하지 않는다
처음에 메모리 풀을 만들기로 결정한 가장 큰 이유는 new/delete를 사용해서 매번 메모리를 할당하고 해제할 때마다 OS에 요청해야 한다고 생각한 것이었습니다.
하지만 C++에서 제공하는 new/delete 연산자는 매번 OS를 호출하는 것이 아니라 먼저 operator new를 호출하고, 일반적인 런타임 heap allocator가 프로세스에 할당된 가상 주소 공간에서 할당할 수 있는 적절한 블록을 찾게 됩니다.
프로세스 Virtual Address Space
- EXE / DLL Image 영역
- 스레드 stack
- PEB / TEB 등 시스템
- Default Heap
- Committed pages
- Reserved (not committed) pages
Windows OS는 프로세스 시작 시 Windows Loader를 통해 실행 파일과 DLL을 매핑하고 stack, default heap 등 실행에 필요한 기본적인 메모리 영역을 준비합니다.
default heap에는 이 과정에서 프로세스 실행에 필요한 메모리 영역이 reserve되고, 그중 초기 사용분이 commit됩니다.
그 후 new 요청은 다음처럼 처리됩니다.
new
-> operator new
-> CRT heap allocator
-> 기본 heap의 현재 committed pages 확인
# 현재 committed된 영역에 반환할 수 있는 적당한 free 블록이 있음
[Committed heap pages]
┌────────┬────────┬────────┬────────┐
│ 사용 중 │ Free │ 사용 중 │ Free │
└────────┴────────┴────────┴────────┘
↑
이 블록을 반환
# 현재 committed된 영역으로 처리가 불가능한 경우
Heap reserved range
┌──────────────────────┬──────────────────────┐
│ Committed │ Reserved │
│ 현재 사용 가능 │ 아직 사용 불가능 │
└──────────────────────┴──────────────────────┘
↓ 추가 commit(Windows Heap Manager)
┌─────────────────────────────────┬───────────┐
│ Committed │ Reserved │
└─────────────────────────────────┴───────────┘
- 만약 이 reserved된 영역도 부족하다면 추가 가상 주소 영역을 확보하거나
VirtualAlloc계열의 함수를 통해 별도로 처리해야 합니다. (이 글에서는 다루지 않는 내용입니다.) - commit된 메모리의 page가 곧바로 RAM의 Working Set에 포함되는 것은 아닙니다.
- commit의 정확한 의미는 RAM 또는 file로 backing할 수 있음을 보장하는 것입니다.
- 실제 가상 주소에 CPU가 접근할 때 물리 메모리의 Working Set에 포함되며, 처음 접근이라면 page fault가 발생할 수 있습니다.
C++에서 new를 사용할 때의 전반적인 흐름을 확인하면 다음과 같습니다.
flowchart TD
N["C++ new expression"] --> O["operator new
런타임 allocator 진입"]
O --> H["프로세스 heap
free 블록과 allocator metadata 관리"]
H -->|"재사용할 블록이 있음"| R["기존 committed page에서 블록 반환"]
H -->|"현재 committed page가 부족함"| C["reserved address 범위에서
추가 page commit 또는 heap 확장"]
R --> U["애플리케이션이 객체 사용"]
C --> U
U --> D["delete expression
소멸자 호출 후 operator delete"]
D --> F["allocator에 블록 반환
즉시 OS 반환은 보장되지 않음"]
따라서 처음 생각했던 new/delete를 사용하면 발생하는 시스템 콜의 오버헤드를 줄인다는 관점은 정확하지 않았습니다.
3. 간소화된 고정 블록 메모리 풀
성능 실험에는 실제 서버의 NrMemoryPool을 그대로 사용하지 않았습니다. 서버 전용 error type, role manager, RAII 블록과 통계를 제거하고 storage, free index, 유효성 검사와 lock만 남긴 공개용 구현을 별도 콘솔 프로젝트에 작성했습니다.
실제 서버 구현과 실험 구현의 공통 핵심은 다음과 같습니다.
메모리 풀 생성
-> aligned storage 한 번 할당
-> 모든 블록 index를 free-list에 등록
Acquire
-> free index pop
-> storage + stride × index 반환
Release
-> 주소와 사용 상태 검증
-> free index push
3.1 Alignment를 포함한 stride를 계산한다
blockSize가 alignment의 배수가 아니면 다음 블록의 시작 주소가 정렬 조건을 만족하지 않을 수 있습니다. 그래서 실제 블록 사이 간격인 stride를 올림 계산합니다.
stride_ =
(blockSize_ + alignment_ - 1) / alignment_ * alignment_;
storageSize_ = stride_ * blockCount_;
storage_ = static_cast<std::byte*>(
_aligned_malloc(storageSize_, alignment_));
for (std::size_t index = blockCount_; index > 0; --index)
{
freeIndices_.push_back(index - 1);
}FixedBlockPool.cpp
예를 들어 블록의 논리 크기가 80바이트이고 alignment가 64바이트라면 stride는 128바이트가 됩니다. 정렬은 보장하지만 블록마다 48바이트의 내부 낭비가 생깁니다.
이와 같이 메모리 풀을 사용했을 때는 내부 단편화가 발생할 가능성이 있습니다.
3.2 Acquire와 Release가 free index를 이동한다
실험 메모리 풀의 핵심 코드는 다음과 같습니다.
std::byte* FixedBlockPool::Acquire() noexcept
{
const LockGuard lock{*this};
if (freeIndices_.empty())
return nullptr;
const std::size_t index = freeIndices_.back();
freeIndices_.pop_back();
inUse_[index] = 1;
return storage_ + stride_ * index;
}
bool FixedBlockPool::Release(std::byte* block) noexcept
{
const LockGuard lock{*this};
if (block == nullptr || storage_ == nullptr)
return false;
const std::uintptr_t storageAddress =
reinterpret_cast<std::uintptr_t>(storage_);
const std::uintptr_t blockAddress =
reinterpret_cast<std::uintptr_t>(block);
if (blockAddress < storageAddress)
return false;
const std::uintptr_t offset = blockAddress - storageAddress;
if (offset >= storageSize_ || offset % stride_ != 0)
return false;
const std::size_t index = offset / stride_;
if (index >= blockCount_ || inUse_[index] == 0)
return false;
inUse_[index] = 0;
freeIndices_.push_back(index);
return true;
}FixedBlockPool.cpp
mutex와 spin variant는 같은 메모리 풀과 workload를 사용하고 lock 구현만 바꿨습니다.
void FixedBlockPool::Lock() const noexcept
{
if (lockKind_ == FixedBlockPoolLockKind::Mutex)
{
mutex_.lock();
return;
}
std::uint32_t pauseCount = 1;
while (spinLock_.test_and_set(std::memory_order_acquire))
{
for (std::uint32_t count = 0; count < pauseCount; ++count)
YieldProcessor();
pauseCount = std::min(pauseCount * 2, 1'024u);
}
}
void FixedBlockPool::Unlock() const noexcept
{
if (lockKind_ == FixedBlockPoolLockKind::Mutex)
mutex_.unlock();
else
spinLock_.clear(std::memory_order_release);
}FixedBlockPool.cpp
spin lock은 짧은 대기 동안 스레드를 sleep시키지 않고 반복 확인합니다. 그래서 짧은 critical section에서는 mutex보다 빠를 수 있지만, 오래 기다리면 CPU를 계속 소비하고 공정성을 보장하기 어렵습니다.
4. Raw storage와 C++ 객체 lifetime을 분리한다
메모리 풀에서 std::byte*를 받았다고 그 주소에 T 객체가 이미 존재하는 것은 아닙니다. raw storage 확보와 C++ 객체 lifetime은 별개의 사건입니다.
std::byte* memory = pool.Acquire();
if (memory == nullptr)
return {};
IoContext* context =
std::construct_at(reinterpret_cast<IoContext*>(memory), sessionKey);
// context 사용
std::destroy_at(context);
pool.Release(memory);IoContextLease.cpp
std::construct_at으로 객체 lifetime을 시작하고 std::destroy_at으로 끝낸 뒤 raw 블록을 반환합니다. 생성자 호출 전에 T*로 객체를 사용하는 것과 소멸자를 생략한 채 블록을 재사용하는 것은 별도 오류입니다.
프로젝트에서는 이 순서를 NrPooledMemoryBlock, I/O context lease와 NrPayloadRef에 나눠 표현했습니다.
flowchart
P["역할별 메모리 풀
고정 용량 raw 블록"] --> B["Move-only
PooledMemoryBlock"]
B --> C["Recv 또는 Send IoContext
construct_at"]
C --> I["WSARecv 또는 WSASend
Pending I/O"]
I --> K["IOCP completion"]
K --> X["destroy_at context"]
X --> P
B --> R["Payload 블록"]
R --> F["PayloadRefControlBlock
ref count와 블록 소유"]
F --> S["Send context와 fan-out 참조"]
S --> Z["마지막 PayloadRef 해제"]
Z --> Y["control block 파괴
payload 블록 반환"]
Y --> P
4.1 I/O context lease가 completion까지 소유한다
I/O context lease는 복사를 금지하고 move만 허용합니다. I/O를 정상적으로 post하면 lease ownership을 pending operation 상태로 이동하고, completion 처리 후 destroy_at과 블록 반환을 수행합니다.
지켜야 하는 조건은 다음과 같습니다.
- outstanding operation 하나는 독립된
OVERLAPPED를 사용합니다. - completion 전에는 context를 파괴하거나 블록을 반환하지 않습니다.
- context 객체를 파괴한 뒤 raw 블록을 메모리 풀에 반환합니다.
- move된 lease는 더 이상 같은 블록을 반환하지 않습니다.
4.2 PayloadRef가 공유 payload의 마지막 반환을 결정한다
send payload는 하나의 I/O context에만 속하지 않을 수 있습니다. broadcast 또는 fan-out에서는 여러 send가 같은 immutable payload를 참조합니다.
NrPayloadRef는 payload 블록을 직접 여러 번 복사하지 않고 control 블록의 reference count를 공유합니다. 마지막 참조가 사라질 때 control 블록을 파괴하고 payload 블록을 원래 role 메모리 풀에 반환합니다.
std::shared_ptr로도 비슷한 lifetime을 표현할 수 있습니다. 프로젝트의 PayloadRef는 payload size class와 bounded 메모리 풀 exhaustion을 기존 runtime error 정책에 연결하기 위해 별도 타입으로 구현했습니다.
5. 콘솔 기반 실험
이번 실험 결과는 실제 IOCP server 프로세스 안에서 수행하지 않았습니다. 서버 구현의 핵심과 동일한 fixed storage 및 free-index 로직만 별도 콘솔 프로젝트로 옮겨 allocator 비용과 중앙 lock 경향을 분리했습니다.
5.1 공통 workload를 정의한다
Private Server의 반복 acquire/release role을 기본 용량으로 가중해 64바이트 aligned stride 대표값을 1,408바이트로 정했습니다. 이는 실제 traffic 빈도 평균이 아니라 실험을 위한 대표 크기입니다.
constexpr std::size_t BlockSize = 1'408;
constexpr std::size_t PoolCapacity = 1'024;
constexpr std::size_t Alignment = 64;
constexpr std::size_t CycleCount = 5'000;
constexpr std::array<std::size_t, 4> ThreadCounts{1, 2, 4, 8};main.cpp
각 cycle은 블록 하나를 얻자마자 반환하지 않습니다. 모든 worker가 합계 1,024개를 확보한 뒤 barrier를 통과하고 반환합니다.
각 worker가 자기 몫을 allocate 또는 acquire
-> 합계 1,024개가 동시에 live
-> allocation phase barrier
-> 각 worker가 자기 블록을 delete 또는 release
-> 다음 cycle
이 구조로 같은 free-list tail 하나만 반복 사용하는 cache-hot 패턴을 피하고, 모든 구현에서 같은 live-set을 유지했습니다.
5.2 new/delete와 메모리 풀이 같은 작업을 수행한다
new/delete는 각 worker가 StandardHeapBlock을 생성해 보관하고 barrier 이후 삭제합니다.
for (std::size_t cycle = 0; cycle < cycleCount; ++cycle)
{
for (std::size_t index = 0; index < blocksPerThread; ++index)
{
StandardHeapBlock* block =
new (std::nothrow) StandardHeapBlock;
if (block == nullptr)
{
++failureCount;
continue;
}
block->bytes[0] = static_cast<std::byte>(index & 0xFF);
blocks[index] = block;
}
phaseBarrier.arrive_and_wait();
// std::vector<StandardHeapBlock*> blocks;
for (StandardHeapBlock*& block : blocks)
{
delete block;
block = nullptr;
}
}main.cpp
메모리 풀은 동일한 위치에서 Acquire/Release를 호출합니다.
for (std::size_t cycle = 0; cycle < cycleCount; ++cycle)
{
for (std::size_t index = 0; index < blocksPerThread; ++index)
{
std::byte* block = pool.Acquire();
if (block == nullptr)
{
++failureCount;
continue;
}
block[0] = static_cast<std::byte>(index & 0xFF);
blocks[index] = block;
}
phaseBarrier.arrive_and_wait();
// std::vector<std::byte*> blocks;
for (std::byte*& block : blocks)
{
if (block != nullptr && !pool.Release(block))
++failureCount;
block = nullptr;
}
}main.cpp
스레드 생성, pointer vector 준비와 warm-up은 timed region 밖에 두었습니다. 각 조건을 5회 반복하고 이상치에 덜 민감한 중앙값을 사용했습니다. checksum, allocation 실패 횟수와 종료 후 Available() == Capacity()도 함께 검증했습니다.
6. 단일 스레드에서의 처리량
환경은 Windows 11, Intel Core Ultra 5 226V, MSVC 19.51 x64 Release(/O2, /GL, /LTCG)입니다. 각 variant는 블록 1,024개를 동시에 보유하는 cycle을 5,000번 반복해 총 5,120,000개의 블록 lifetime을 처리했습니다.
| 1 스레드 variant | 중앙값 처리량 |
|---|---|
new/delete | 27.13M ops/s |
| Mutex 메모리 풀 | 27.36M ops/s |
| Spin 메모리 풀 | 60.45M ops/s |
Mutex 메모리 풀에는 lock, free-list 갱신, 주소 검증과 사용 상태 metadata 비용이 포함되어 있습니다. 하지만 단일 스레드에서는 경합 상황이 발생하지 않기 때문에 그 비용이 그렇게 크지 않습니다.
Spin 메모리 풀의 단일 스레드 결과는 lock을 실제로 기다리는 시간이 거의 없는 경로에서 atomic_flag fast path가 짧았음을 보여줍니다. 다만 실제 서버에서는 메모리 풀에 접근하는 스레드가 다수 있기 때문에 이 수치가 더 빠르게 작동한다고 볼 수는 없었습니다.
7. 멀티 스레드에서의 처리량
이번에는 같은 작업을 1, 2, 4, 8개의 스레드로 나눠 실행했습니다.
| 스레드 수 | new/delete | Mutex 메모리 풀 | Spin 메모리 풀 |
|---|---|---|---|
| 1 | 27.13M ops/s | 27.36M ops/s | 60.45M ops/s |
| 2 | 26.56M ops/s | 20.16M ops/s | 38.62M ops/s |
| 4 | 28.78M ops/s | 13.15M ops/s | 25.75M ops/s |
| 8 | 14.02M ops/s | 4.68M ops/s | 10.46M ops/s |
스레드 4개 조건에서 Mutex 메모리 풀은 스레드 1개 처리량의 약 48%로 감소했습니다. new/delete는 같은 조건에서 28.78M ops/s를 기록했습니다.
메모리 풀의 주소 계산은 O(1)이지만 모든 Acquire/Release가 같은 lock을 통과합니다.
flowchart LR
W1["Worker 1"] --> L["Shared lock"]
W2["Worker 2"] --> L
W3["Worker 3"] --> L
W4["Worker 4"] --> L
L --> Q["freeIndices tail
inUse metadata"]
멀티 스레드 상황에서는 다음과 같은 비용이 추가적으로 발생합니다.
- 짧은 critical section이 하나의 순서로 직렬화됩니다.
- lock ownership과 free-list cache line이 CPU 사이를 이동합니다.
- mutex 대기와 wake-up 가능성이 생깁니다.
- 공유 metadata와 통계 갱신도 같은 경합 지점에 모입니다.
같은 workload에서 spin lock은 Mutex 메모리 풀보다 약 1.9~2.2배 높은 처리량을 보였습니다. 하지만 스레드 4개 조건에서도 단일 스레드보다 처리량이 감소했습니다. 대기 전략을 바꿨을 뿐 하나의 shared free-list라는 직렬화 지점은 남아 있기 때문입니다.
스레드 8개의 barrier-only control은 new/delete 실행 시간의 약 62%였습니다. 스레드 수가 많아질수록 현재 메모리 풀이 가지고 있는 단일 접근 지점에 대한 경합 비용은 점점 증가하고, 그에 비해 allocator가 메모리를 할당할 때는 경합이 없기 때문에 상대적으로 빠른 속도를 보여주고 있습니다.
8. 메모리 사용량은 용량에 맞춰 고정된다
각 variant를 별도 프로세스로 실행하고 pointer vector 및 memory API warm-up 이후를 baseline으로 잡았습니다. 블록 1,024개의 첫 byte와 마지막 byte를 touch한 뒤 GetProcessMemoryInfo로 PrivateUsage와 Working Set을 기록했습니다.
논리적으로 동시에 사용하는 payload 크기는 다음과 같습니다.
1,408 bytes × 블록 1,024개 = 1,441,792 bytes
| Variant와 상태 | Private Usage 증가량 |
|---|---|
new/delete all-live | 1,740,800 bytes |
new/delete delete 후 | 647,168 bytes |
| Mutex 메모리 풀 생성 후 | 1,458,176 bytes |
| Mutex 메모리 풀 all-live | 1,458,176 bytes |
| Mutex 메모리 풀 release 후 | 1,458,176 bytes |
| Mutex 메모리 풀 파괴 후 | 12,288 bytes |
메모리 풀은 생성 시 용량에 필요한 storage와 metadata를 확보했습니다. all-live와 release 이후에도 PrivateUsage가 변하지 않았습니다. 반환된 블록을 다음 operation에서 다시 사용하기 위해 계속 보유하기 때문입니다.
메모리 풀이 담당하는 경로의 storage 크기가 초기 용량으로 결정되고, all-live 상태에서도 추가 private commit이 발생하지 않았다는 점이 핵심입니다.
반대로 실제 사용량이 적어도 storage를 계속 보유한다는 trade-off가 있습니다. 또한 메모리 풀 생성 직후 Working Set 증가량은 28,672바이트였고 모든 블록 page를 touch한 후 1,470,464바이트로 증가했습니다. Storage를 확보해도 물리 page가 모두 resident가 된 것은 아니므로 최초 접근 page fault까지 통제하려면 초기화 단계에서 pre-touch가 필요합니다.
new/delete 후 남은 647KiB도 leak이라고 판단할 수 없습니다. 범용 allocator가 다음 요청에 재사용할 heap memory를 유지할 수 있기 때문입니다.
9. 멀티 스레드 환경의 고성능 메모리 풀
이번에 Private Server에 적용한 고정 블록 메모리 풀의 경우 free-list에 접근하는 여러 스레드의 경합 처리가 없었기 때문에 스레드가 많아질수록 경합 비용이 증가하는 것을 확인할 수 있었습니다.
그러나 여러 스레드가 존재하는 환경에서도 메모리 풀을 고성능으로 사용할 수 있는 방법은 있습니다.
TCMalloc과 DPDK 메모리 풀 같은 고성능 allocator는 worker 또는 CPU별 local cache를 둡니다. Local cache는 별도 payload storage가 아니라 중앙 메모리 풀에서 가져온 free 블록 주소 또는 index를 잠시 보관하는 작은 free-list입니다.
flowchart LR
C["중앙 메모리 풀
고정 storage와 용량"] -->|"16개 batch refill"| L1["Worker 1
local free 블록"]
C -->|"16개 batch refill"| L2["Worker 2
local free 블록"]
C -->|"16개 batch refill"| L3["Worker 3
local free 블록"]
L1 -->|"lock 없는 fast path"| A1["Acquire / Release"]
L2 -->|"lock 없는 fast path"| A2["Acquire / Release"]
L3 -->|"lock 없는 fast path"| A3["Acquire / Release"]
L1 -->|"cache 상한 초과 시 batch flush"| C
L2 -->|"cache 상한 초과 시 batch flush"| C
L3 -->|"cache 상한 초과 시 batch flush"| C
Local cache가 비었을 때 중앙 메모리 풀 lock을 한 번 잡고 16개를 가져오면 이후 요청은 worker-local free-list에서 처리할 수 있습니다. Local cache가 상한을 넘을 때도 여러 블록을 묶어 반환합니다. 공유 lock을 없애지 않더라도 접근 빈도를 최대 batch 단위로 줄이는 구조입니다.
대신 다음 trade-off가 생깁니다.
- 사용하지 않는 블록이 worker cache에 남아 있을 수 있습니다.
- 중앙 free-list가 비어도 다른 worker cache에는 블록이 남아 있을 수 있습니다.
- I/O를 post한 worker와 completion을 처리한 worker가 다르면 스레드 간 release가 발생합니다.
- worker 종료와 server shutdown에서 local cache를 중앙으로 drain해야 합니다.
IOCP 서버에는 allocator 최적화보다 더 단순한 방법도 있습니다. Session당 outstanding recv를 하나로 제한한다면 RecvIoContext 하나를 session이 계속 소유하고 completion 이후 다음 WSARecv에 재사용할 수 있습니다. 이 경우 빠른 allocation을 만드는 것이 아니라 allocation 자체를 hot path에서 제거합니다.
그러나 send-path에서 상대방에게 전달할 Payload는 broadcast의 마지막 release worker를 미리 알기 어렵기 때문에 worker-local cache와 별도의 remote-free queue가 필요할 수 있습니다.
10. 제한된 실험에서 경향을 확인한다
이번 microbenchmark는 실제 IOCP server의 operation lifetime, post와 completion worker의 분리, payload fan-out과 생성자·소멸자 비용을 그대로 재현하지 않았습니다.
그래서 제한된 블록 크기와 전체 용량 phase를 반복한 결과이므로 실제 서버 처리량을 예측하는 수치로 사용할 수 없습니다.
다만 동일한 workload 안에서 다음 경향은 확인했습니다.
- 단일 스레드 고정 블록 메모리 풀은
new/delete보다 특별히 느리지 않았습니다. - 여러 스레드가 하나의 중앙 lock을 공유하면 처리량이 감소했습니다.
- spin lock은 짧은 경합에서 mutex보다 빨랐지만 공유 직렬화 지점을 제거하지 못했습니다.
- 고정 용량 메모리 풀은 all-live와 release 상태에서 예측 가능한 storage 크기를 유지했습니다.
정리하며
처음에는 메모리 풀을 new/delete보다 빠른 할당 방법으로 생각했지만, 실험을 하며 경합이 존재하는 환경에서는 메모리 풀이 가지는 장점의 일부가 퇴색되는 경우가 있다는 것을 확인했습니다.
그러나 프로젝트에서 메모리 풀을 유지할 이유는 여전히 분명했습니다.
- pending I/O context와 shared payload lifetime을 move-only RAII로 표현합니다.
- 역할별 블록 크기와 용량으로 메모리 풀이 담당하는 메모리 및 동시 작업 상한을 정합니다.
- high-water mark와 exhaustion을 관측 가능한 resource pressure로 바꿉니다.
따라서 현재 메모리 풀은 평균 allocation 속도를 무조건 높이는 장치라기보다 서버의 lifetime과 bounded resource policy를 만드는 기반입니다. 멀티 스레드 성능을 높이려면 더 빠른 lock 하나를 선택하는 데서 끝나지 않고, context 재사용, worker-local cache, batch refill과 remote-free처럼 공유 지점에 접근하는 빈도를 줄여야 합니다.
참고 자료
- Microsoft Learn:
newanddeleteoperators —newexpression과operator new,delete의 관계 - Microsoft Learn: Heap Functions — 프로세스 default heap과 reserved/committed page의 성장
- Microsoft Learn: HeapAlloc — heap 블록 allocation, alignment와 serialization
- Microsoft Learn:
WSARecv—OVERLAPPEDoperation과 context lifetime - Microsoft Learn:
GetProcessMemoryInfo— 프로세스 memory counter 측정 API - TCMalloc Design — CPU별/스레드별 front-end cache와 central free-list
- DPDK 메모리 풀 라이브러리 — fixed-size 객체 메모리 풀, 코어별 cache와 bulk get/put
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.