Strand-per-thread (thread-affinity actor) 모델의 .NET 구현체.
각 actor는 생성 시점에 단 하나의 OS 워커 스레드(Strand)에 영구 바인딩되어, 이 actor의 모든 작업이 항상 같은 스레드에서 직렬 실행된다. 따라서 actor 내부 상태는 lock 없이 안전하게 읽고 쓸 수 있다.
같은 저장소의
ActorStrandMmorpgServer는 이 라이브러리 위에 만든 MMO 서버 데모다.
┌──────────────────────────────────────────────┐
IO 스레드 ──▶ │ ConcurrentDictionary 조회 → actor.Tell(...) │
│ (즉시 strand 큐에 push, 1 hop) │
└────────┬─────────────────────────────────────┘
│ enqueue
┌─────────▼──────────┐ ┌─────────────────┐
│ Strand #N 큐(MPSC) │ ──▶│ 전용 워커 스레드 │
└─────────┬──────────┘ │ + TimerHeap │
│ └─────────────────┘
▼
actor의 모든 작업은 항상 같은 스레드 = lock 없이 안전
- 한
StrandActor는 한Strand에만 바인딩 — pin은 영구. Tell/TellAfter/AskAsync는 어떤 스레드에서든 호출 가능, 모두 그 actor의 strand 큐로 들어간다.- 큐 dequeue는 그 strand의 워커 스레드 한 명만 한다 → 직렬성 자동 보장.
| 측면 | JobDispatcherNET | ActorStrandNET |
|---|---|---|
| 객체-스레드 결합 | 무관 (큐만 가짐) | 1:1 영구 바인딩 (생성 시 결정) |
Tell 호출자 |
호출 스레드가 직접 flush | 단순 enqueue, 워커가 처리 |
| 워커 스레드 역할 | ThreadLocal 자리 (대부분 idle) | 실제 처리 주체 |
| IO 스레드 부담 | actor 람다 직접 실행 | enqueue 후 즉시 자유 |
| 부하 분산 | 자동 (호출자가 일함) | strand 할당 정책에 의존 |
| Cross-strand cost | 없음 | hop 비용 누적 가능 (pinning 약점) |
이 라이브러리는 워커 활용·캐시 친화·IO 응답성을 중시하는 워크로드에 적합하다.
# 라이브러리만
dotnet build ActorStrandNET\ActorStrandNET.csproj
# 입문 데모 (Tell / TellAfter / AskAsync / struct 메시지 / 메트릭)
dotnet run --project ExampleConsoleApp
# 채팅 서버 (Room/User actor, 핫패스 broadcast, heartbeat, 일관 스냅샷)
dotnet run --project ExampleChatServer
# 섹터 기반 MMO (cross-strand hop trade-off, IsTransferring 보호)
dotnet run --project ExampleSectorServer
# 단일 존 MMO 데모 (PlayerActor, AoE, 부활/회복 타이머)
dotnet run --project ActorStrandMmorpgServer
# 부하 검증용 본격 MMO 서버 (TCP, NPC AI, 봇 16대 동시 접속)
dotnet run --project AdvancedMmorpgServer
# MonoGame 봇 클라이언트 (별도 터미널에서)
dotnet run --project AdvancedMmorpgClientnet10.0 SDK 필요. 모든 프로젝트는 솔루션에 묶여있지 않고 독립적으로 빌드된다.
| 프로젝트 | 보여주는 것 |
|---|---|
ExampleConsoleApp |
가장 짧은 사용 예 / thread-affinity 검증 / 자기 큐를 통과시키면 평범한 Dictionary가 안전해진다는 핵심 패턴 |
ExampleChatServer |
한 사용자/방 = 한 actor. 같은 방 동시 broadcast의 직렬화, 자기복제 heartbeat, AskAsync fan-out 스냅샷 |
ExampleSectorServer |
9 섹터 → strand 매핑. 같은 섹터 hop 0회 vs cross-sector hop의 trade-off, 섹터 이동 보호(IsTransferring) |
ActorStrandMmorpgServer |
단일 존 MMO 데모. AssignByKey 안정 해시, struct 메시지, ITimerHandle.Cancel(부활/회복), DrainAsync, 메트릭 |
AdvancedMmorpgServer |
TCP 서버 + NPC AI 50마리 + SessionActor 패턴 (Sequencer·외부 inbound 큐 불필요). 16봇 부하 검증용. |
AdvancedMmorpgClient |
MonoGame 부감 시점 봇 클라이언트. Server와 와이어 호환 — JobDispatcher 버전 서버에도 그대로 접속 가능. |
using ActorStrandNET;
// 1) dispatcher 생성 (워커 스레드 4개)
await using var disp = new StrandDispatcher(strandCount: 4);
disp.Start();
// 2) actor 정의
public sealed class Counter : StrandActor<Counter>
{
private int _n;
public Counter(Strand s) : base(s) { }
internal void Inc() => _n++; // lock 없음 — 자기 strand 워커에서만 실행
public int Snapshot() => _n;
}
public readonly record struct IncCmd : IActorMessage<Counter>
{
public void Handle(Counter c) => c.Inc(); // struct 메시지: zero-alloc 핫패스
}
// 3) actor를 strand에 바인딩 (할당 정책 선택)
var counter = new Counter(disp.AssignByKey("counter-1"));
counter.Start();
// 4) 어디서든 enqueue
for (int i = 0; i < 1000; i++) counter.Tell(new IncCmd());
// 5) 결과를 읽고 싶으면 AskAsync (cross-thread → strand에서 계산)
int n = await counter.AskAsync(() => counter.Snapshot());
await disp.StopAsync(TimeSpan.FromSeconds(5));| 파일 | 역할 |
|---|---|
StrandActor.cs |
모든 actor의 base. Tell/TellAfter/AskAsync. CRTP 변형(StrandActor<TSelf>)은 struct 메시지 zero-alloc 경로 제공. |
Strand.cs |
OS 워커 스레드 + MPSC 큐(ConcurrentQueue) + 신호(ManualResetEventSlim) + 자기 전용 TimerHeap. |
StrandDispatcher.cs |
N개의 strand 관리. 할당 정책: AssignRoundRobin / AssignByKey / AssignLeastLoaded. DrainAsync / StopAsync 제공. |
StrandDispatcherOptions.cs |
큐 capacity, drop policy, enqueue timeout, batch size, slow-job threshold, CPU 핀, 메트릭 sink, error handler 등 모든 운영 노브. |
IActorMessage.cs |
struct 메시지 인터페이스. IActorMessage<TActor>.Handle(TActor). |
JobEntry.cs / ObjectPool.cs |
큐 wrapper 풀링 — Tell 한 번에 힙 할당 0회(closure 없을 시). |
TimerHeap.cs |
strand 전용 우선순위 큐 기반 타이머. 락 0개. 모든 cancel은 lazy invalidation. |
ITimerHandle.cs |
TellAfter 반환 핸들. 발화 전 Cancel() 가능 — 부활/회복 같은 보류 작업 무효화. |
IMetricsSink.cs / CountingMetricsSink.cs |
핫패스 메트릭 콜백 (enqueue/execute/drop/slow/strand-error). |
DropPolicy.cs |
Wait / DropNewest / Throw — 큐 가득찼을 때 정책. |
Hashing.cs |
FNV-1a 32비트 / int·long mix. AssignByKey의 안정 분포(재시작 후에도 동일)를 위한 비-randomized 해시. |
ActorErrorContext.cs |
error handler 콜백 인자 — strand id, actor id, message type, exception. |
CpuAffinity.cs |
PinThreadsToCores 옵션의 best-effort 구현 (Windows 64코어 이하). |
전체 코드량이 작아 한 번 훑어보면 모델 전체를 파악할 수 있도록 의도되어 있다.
- Bounded queue + drop policy + enqueue timeout — IO 스레드가 무한 block되지 않도록 보장.
- MaxBatchSize — 큐 폭주에도 60Hz 타이머 fairness 유지 (한 batch 후 반드시 timer 체크).
- System message bypass — schedule/barrier 같은 운영 메시지는 capacity 무관하게 enqueue되어 cascading slowdown 방지.
- DrainAsync(timeout) — 호출 시점까지 enqueue된 모든 작업을 flush. DB 백업·snapshot 직전 사용.
- StopAsync(timeout) — 그레이스풀 셧다운. 워커 join 후 큐의 잔여는 안전 폐기.
- Slow-job watchdog — 한 작업이 임계 초과 시
IMetricsSink.OnSlowJob호출 (blocking IO·무한루프 조기 탐지). - GlobalErrorHandler / per-dispatcher ErrorHandler — actor 코드 예외를 워커가 죽지 않도록 수렴.
- CountingMetricsSink — 처리량·dwell time·실행 시간·slow job·drop 집계 (Prometheus 등으로 내보내기 전 단계).
// 1) Action 경로 — 코드는 짧지만 closure가 변수 캡처하면 람다당 1 alloc
actor.Tell(() => actor.Move(x, y));
// 2) struct 메시지 경로 — 박싱·closure 없음
public readonly record struct MoveCmd(float X, float Y) : IActorMessage<PlayerActor>
{
public void Handle(PlayerActor a) => a.OnMove(X, Y);
}
actor.Tell(new MoveCmd(x, y)); // JobEntry wrapper는 ObjectPool에서 재사용 → alloc 060Hz 게임 루프에서 actor당 수십 메시지/초가 흘러도 GC 압력이 거의 없다.
NOTE:
Tell<TMessage>제네릭은 (TMessage, TActor) 조합마다 JIT 코드 + 정적 풀이 생성된다. 메시지 타입이 수백 개 되면 cold-start와 메모리 사용량에 영향 — NativeAOT 빌드 시 모든 조합이 정적으로 reachable해야 한다.
disp.AssignRoundRobin(); // 단순 균등 분배
disp.AssignByKey(playerId); // FNV-1a 안정 해시 — 같은 키는 항상 같은 strand
disp.AssignByKey(itemId); // int / long 오버로드도 제공 (Mix32 / Mix64)
disp.AssignLeastLoaded(); // Power-of-Two-Choices: 무작위 2개 sample 후 큐 짧은 쪽AssignByKey는 재시작 후에도 같은 분포 —string.GetHashCode의 randomize 문제를 회피.AssignLeastLoaded는 N개 strand 풀스캔이 아닌 Power of Two Random Choices (Mitzenmacher 2001) — 거의 동등한 분산을 O(1)에 달성.
// 같은 strand 워커 스레드 안에서 자기 actor에게 AskAsync 하면 즉시 throw.
// 이미 그 워커가 await로 멈추면 자기 큐를 자기가 처리할 수 없어 영구 hang이 되기 때문.
//
// 해결: 자기 strand 안에서는 AskAsync 대신 그냥 메서드를 직접 부른다 (이미 올바른 스레드다).StrandActor.IsOnMyStrand / Strand.Current로 현재 스레드를 검사할 수 있다.
- 단일 존 / 인스턴스 던전 / 채팅 서버 — 객체 상호작용이 한 그룹 안에 집중되어 cross-strand hop 적음.
- 세션이 독립적인 서비스 — 각 세션을 한 strand에 pin하면 캐시 친화 극대화.
- EVE Online처럼 솔라시스템 단위 pin — 단, 큰 규모 PvP는 부하 불균형의 대가를 치름.
반대로 글로벌 PvP 난전·대규모 AoE가 흔한 워크로드에서는 cross-strand 비용이 누적되어 JobDispatcher 모델이 유리할 수 있다. 어느 쪽이 빠른지는 실제 워크로드로 측정해야 단정할 수 있다.
- TCP 네트워크 모드 없음 — 모델 시연이 목적. 추가하려면 IO 스레드에서
World.OnXxx같은 라우팅 진입점을 호출하면 된다. - 동적 actor 마이그레이션 없음 — strand 바인딩은 영구. 재배치가 필요하면 별도 설계.
- 분산 전략 없음 — 단일 프로세스. 노드 간 라우팅은 사용자 코드에서.
LICENSE 파일 참조.