Skip to content

Repository files navigation

ActorStrandNET

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의 워커 스레드 한 명만 한다 → 직렬성 자동 보장.

ActorStrand vs JobDispatcher (자매 라이브러리 비교)

측면 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 AdvancedMmorpgClient

net10.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));

주요 컴포넌트 (ActorStrandNET 패키지)

파일 역할
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 등으로 내보내기 전 단계).

핫패스의 zero-alloc 경로

// 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 0

60Hz 게임 루프에서 actor당 수십 메시지/초가 흘러도 GC 압력이 거의 없다.

NOTE: Tell<TMessage> 제네릭은 (TMessage, TActor) 조합마다 JIT 코드 + 정적 풀이 생성된다. 메시지 타입이 수백 개 되면 cold-start와 메모리 사용량에 영향 — NativeAOT 빌드 시 모든 조합이 정적으로 reachable해야 한다.


strand 할당 정책

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)에 달성.

AskAsync deadlock 가드

// 같은 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 파일 참조.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages