자료실

Photon의 멀티스레딩
작성자 | admin 2021-11-08  |    조회수 : 1265  




아래 기사를 한국어 번역했습니다.

Multithreading in Photon



이 기사에서는 백엔드의 멀티스레딩에 대해 소개합니다.
● 구현 방법에 대해
● 사용 방법에 대해
● 기능에 대해
● 당사에서의 개발에 대해

이것들은 모두, 서버 SDK 코드의 수정, 독자적인 플러그인 기술, 서버 애플리케이션의 스크래치부터의 시작 등, 서버 측에서의 개발이 실행될 때에 나오는 질문들입니다.


Photon에서는 어떻게 멀티스레딩 문제를 해결할까요?

Photon의 서버 애플리케이션은 여러 클라이언트의 접속으로부터 동시에 리퀘스트를 받습니다. 이러한 접속을 피어 라고 합니다. 이러한 리퀘스트는 큐를 필요로 합니다. 각 피어에 1개의 큐입니다. 여러 피어가 같은 룸에 접속되어 있는 경우, 그 피어의 큐는 1개로 합쳐져 룸 큐가 됩니다.

이러한 룸은 수 천개까지 존재하여, 많은 룸의 리퀘스트 큐는 병렬 처리됩니다.

Photon의 태스크 큐 구현의 기초로써, Jetlang 라이브러리를 바탕으로 개발된 Retlang 라이브러리가 사용되었습니다.


태스크, async/await를 사용하지 않는 것은 왜일까요?

이것은 아래와 같은 조건 때문입니다.
1. Photon Server의 개발은 이러한 기능이 생기기 전부터 시작되었습니다.
2. 파이버(Fiber)에 의해 실행되는 태스크 수는 방대해 매 초마다 수 만 태스크에 이릅니다. 그렇기 때문에 GC(Garbage Collector)도 발생할 가능성이 있는 또 다른 추상화(abstraction) 를 추가할 의미가 없었습니다. 파이버의 추상화는 매우 섬세한 것이라고 말할 수 있습니다.
3. 파이버와 같은 기능을 가진 TaskScheduler라는 것도 있어, 댓글에서 TaskSchesuler에 대해 알려주신적도 있습니다만, 휠 전체를 다시 생각하고 싶지는 않았습니다. 

파이버(Fiber)는 대체 무엇일까요?

커맨드 큐를 구현하는 클래스를 파이버라고 말합니다. 커맨드는 차례로 정렬되어 실행됩니다. 이것은 여러 라이터와 하나의 리더라는 템플릿으로 구현된다고 말할 수 있습니다. 다시 한 번 말하지만, 커맨드는 수신한 순서대로 실행되는 점에 주의해 주십시오. 이것이 멀티스레드의 환경에서의 데이터 액세스의 안전성의 기초가 됩니다.

Photon에서 사용되는 파이버는 PoolFiber 이라는 하나의 타입입니다만, 라이브러리는 5개의 타입을 제공합니다. 5개 모두, IFiber 인터페이스를 구현하고 있습니다. 각 타입에 대해 간단히 소개합니다.

● ThreadFiber - 전용 스레드에 뒷받침되는 IFiber. 자주 발생하거나 퍼포먼스에 영향받기 쉬운 오퍼레이션에 사용.
● PoolFiber - .NET 스레드 풀에 뒷받침되는 IFiber. 이것도 순서대로 실행되어, 한 번에 1개의 풀 스레드만이 실행됨. 빈도가 높지 않고 퍼포먼스의 영향을 쉽게 받지 않는 실행이나, 스레드 카운트를 실행하고 싶지 않을 경우에 사용.
● FormFiber / DispatchFiber - WinForms 및 WPF의 메시지 펌프에 뒷받침되는 IFiber. FormFiber / DispatchFiber - Invoke 또는 BeginInvoke를 호출하여 다른 스레드에서 윈도우로 통신할 필요를 전면적으로 배제.
● StubFiber - 결정성 테스트에 편리. 실행중에 치밀한 컨트롤이 제공되기 때문에 테스트 레이스가 간단. 호출측의 스레드의 액션을 모두 실행.


PoolFiber에 대해

PoolFiber에서의 태스크 실행에 대해 설명합니다. 스레드 풀을 사용해도, 태스크는 순서대로 실행되어 한번에 사용되는 스레드는 1개입니다. 아래와 같은 움직임이 됩니다.

1. 파이버에 태스크를 입력(enqueue), 실행합니다. QueueUserWorkItem을 호출하여 이것을 실행합니다. 어느 시점에서 풀에서 1개의 스레드가 선택되어, 그 스레드가 태스크를 실행합니다.
2.처음 태스크 실행중, 큐에 몇 개의 태스크를 입력합니다. 처음의 태스크가 끝날 무렵쯤, 모든 새로운 태스크가 큐에서 나와 다시 QueueUserWorkItem가 호출되어 이 모든 태스크가 실행으로 송신됩니다. 풀의 새로운 스레드가 선택되어 완료되고, 큐 안에 태스크가 있을 경우에는 처음부터 다시 반복하게 됩니다.

매번 풀의 새로운 스레드에 의해 태스크의 배치(batch)가 새로 실행됩니다. 단, 실행되는 것은 한 번에 1개뿐입니다. 그렇기 때문에 게임 룸에서 움직이기 위한 태스크가 모두 파이버 안에 있으면, 그 태스크로부터 룸의 데이터에 안전하게 액세스할 수 있게 됩니다. 다른 파이버에서 실행중인 태스크에서 오브젝트에 액세스할 경우에는, 동기화가 필요합니다.



다음 그림이 알기 쉬울 듯 합니다.

태스크A, B, C를 파이버에 입력합니다.

아래와 같이 실행됩니다.



태스크A는 하나의 스레드 (가운데 선)에서 실행되어, 태스크B는 또 다른 스레드에서 실행됩니다. 태스크C에는 시스템이 3번째 스레드를 선택합니다. 태스크가 보다 자주 입력(enqueue)되는 활발한 움직임의 경우에는 다음과 같이 됩니다.
태스크 A의 그룹이 첫번째 스레드를 사용하고, 태스크 B의 그룹이 두번째 스레드를 사용, 태스크 C의 그룹이 세번째 스레드를 사용합니다. 단, 이들 그룹/태스크가 타임라인에서 교차하는 일은 없다는 것을 이해해둘 필요가 있습니다. 모든 태스크는 반드시 순서대로 실행됩니다.


PoolFiber를 사용하는 이유

Photon에서는 모든 곳에서 PoolFiber를 사용하고 있습니다. 첫번째 이유로, PoolFiber는 추가 스레드를 작성하지 않고, 필요에 따라 자신의 파이버에 추가할 수 있다는 점을 들 수 있습니다. 이것에 대해 다소 조정을 하여 더이상 멈추지 않도록 하였습니다. 즉, PoolFiber.Stop은 현재의 태스크를 정지시키지 않는다는 것입니다. 이것은 중요한 점이었습니다.

어떤 스레드에서도 파이버에 태스크를 설정할 수 있습니다. 모두 스레드 세이프(thread-safe)죠. 현재 실행중인 태스크는 실행되고 있는 파이버에 새로운 태스크를 입력(enqueue)할 수도 있습니다.

파이버에 태스크를 설정하는 방법은 3가지입니다.

1. 큐에 태스크를 넣는다
2. 어느 정도 인터벌을 거친 큐에 태스크를 넣는다
3. 정기적으로 실행되는 큐에 태스크를 넣는다

다음과 같이 설명할 수 있습니다.

// 태스크의 enqueue
 fiber.Enqueue(()=>{some action code;}); 

// 10초안에 태스크가 실행되도록 스케줄링 
var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000);
... 
// 타이머를 멈춘다 
scheduledAction.Dispose()
 
// 10초안에 태스크가 실행되고, 5초마다 반복되도록 스케줄링 
var scheduledAction = fiber.Schedule(()=>{some action code;}, 10_000, 5_000);
... 
// 타이머를 멈춘다
scheduledAction.Dispose()


일정의 인터벌로 실행되는 태스크는 fiber.Schedule에서 응답되는 오브젝트로의 리퍼런스를 보존하는 것이 중요합니다. 이와 같은 태스크의 실행을 정지시키기 위해서는 이것이 유일한 방법입니다.


Executor

그러면 Executor 설명으로 넘어가겠습니다. 실제로 태스크를 실행하는 클래스라는 것이 존재합니다. 클래스는 Execute(Action a) 메소드 및 Execute(List a) 메소트를 구현합니다. PoolFiber은 두번째 메소드를 사용합니다. 즉, 태스크가 배치(batch)에서 executor에 넘어가게 되는 것입니다. 그 다음에 무엇이 일어날지는 executor에 따라 달라집니다. 처음에는 DefaultExecutor 클래스를 사용했었고, 아래와 같이 실행되었습니다.

public void Execute(List toExecute)
{
   foreach (var action in toExecute)
   {
      Execute(action);
   }
}

public void Execute(Action toExecute)
{
   if (_running)
   {
      toExecute();
   }
}

실제로는 이것만으로는 충분하지 않았습니다.
"액션" 안에 예외가 있으면 toExecute 리스트 내의 다른 모든 것이 스킵되어 버렸습니다.
그래서 디폴트로 FailSafeBatchExecutor 를 사용, try/catch 를 루프에 추가했습니다. 특별한 경우가 아니면 이 executor를 사용하는 것을 추천합니다.
이 executor는 당사가 추가한 것으로, github 등에 있는 버전에서는 아직 대응하지 않고 있습니다.



그 외 당사가 실시한 것들

BeforeAfterExecutor

그 후, 로그 문제를 해결하기 위해 executor를 하나 더 추가했습니다. 그것이 BeforeAfterExecutor 입니다. 여기에 패스된 executor를 "랩"합니다. 아무것도 패스되지 않을 경우에는 FailSafeBatchExecutor 가 작성됩니다. BeforeAfterExecutor 의 특징적인 기능은, 태스크 리스트 실행 전에 액션을 실행해 태스크 리스트 실행 후에 다른 액션을 실행하는 기능입니다. 생성자(constructor)는 다음과 같습니다.

public BeforeAfterExecutor(Action beforeExecute, Action afterExecute, IExecutor executor = null)

사용 목적은 무엇일까요? 파이버와 executor의 오너는 동일합니다. executor 작성시, 2개의 액션이 오너에 패스됩니다. 전자는 스레드 컨텍스트에 key/value pairs를 추가하고, 후자는 그것들을 지우는 것으로, 클리너로써의 기능을 맡습니다. 스레드 컨텍스트에 추가된 페어는 로깅 시스템에 의해 메시지에 추가되어, 메시지를 남긴 오브젝트의 메타데이터를 확인할 수 있게 됩니다.

예:

var beforeAction = ()=> 
{ 
   log4net.ThreadContext.Properties["Meta1"] = "value"; 
}; 

var afterAction = () => ThreadContext.Properties.Clear(); 

//executorを作成する 
var e = new BeforeAfterExecutor(beforeAction, afterAction); 

//PoolFiberを作成する 
var fiber = new PoolFiber(e); 


파이버 내에서 실행하는 태스크로부터 무언가가 로깅되면, log4net에 의해 value 값을 가지는 Meta1 태그가 추가됩니다.


ExtendedPoolFiber and ExtendedFailSafeExecutor

retlang의 오리지널 버전에는 존재하지 않고, 후에 당사에서 개발한 것이 하나 더 있습니다. 이에 앞서 다음 스토리 "There is PoolFiber" 가 있었습니다(.NET 스레드 풀에 더해 실행되고 있음). 이 파이버가 실행하는 태스크에서는 HTTP 리퀘스트를 동기적으로 실행할 필요가 있었습니다.

아래와 같이 심플한 방법으로 실행했습니다.

1. 리퀘스트 실행 전에 동기 이벤트를 작성합니다. 
2. 리퀘스트를 실행하는 태스크는 다른 파이버에 송신되어, 완료시에 시그널화한 스테이지에 동기 이벤트를 넣습니다. 
3. 그 후, 동기 이벤트를 기다립니다. 

이것은 확장성의 관점에서는 베스트 솔루션이라고는 말하기 어렵게도, 예기치 못한 장애가 이어졌습니다. 스텝 2에서 다른 파이버에 넣은 태스크가, 동기 이벤트를 기다리기 시작한 그 스레드의 큐에 들어가버린 것이었습니다. 데드락(deadlock)이었습니다. 매번 이렇게 되는 것은 아니었지만, 충분히 걱정되는 사항이었습니다.

해결책은 ExtendedPoolFiber와 ExtendedFailSafeExecutor에 구현되었습니다. 파이버 전체를 보류하는 아이디어가 떠오른 것이었습니다. 그러면 새로운 태스크를 큐에 누적하면서 실행하지 않는 것이 가능하게 됩니다. 파이버를 보류하기 위해 Pause 메소드를 호출합니다. Pause 메소드가 호출된 직후, 현재의 태스크가 완료될 때까지의 파이버(파이버 executor)가 대기 상태가 되어, 일시 정지됩니다. 그 외 모든 태스크는 2개의 이벤트 중 첫번째 대기 상태에 들어갑니다.

1. Resume 메소드를 호출합니다.
2. 타임 아웃(Pause 메소드를 호출했을 때 특정됨)됩니다.  Resume 메소드로 모든 큐가 완료된 태스크 전에 실행된 태스크를 설정할 수 있습니다. 


이 방법은 플러그인이 HTTP 리퀘스트를 사용해 룸 스테이트를 로딩할 필요가 있을 때 사용합니다. 룸의 최신 스테이트를 바로 플레이어에게 반영시키기 위해, 룸의 파이버를 보류합니다. Resume 메소드를 호출할 경우, 여기에 로딩한 스테이트를 적용하는 태스크에 패스하고, 다른 태스크들은 이미 최신 스테이트로 움직이고 있습니다.

참고로 파이버를 완전히 기능 정지시키고 보류할 필요가 있는 것은, 게임 룸의 태스크 큐에 _ThreadFiber를 사용하기 위해서입니다.


IFiberAction

IFiberAction 은 GC의 로딩을 가볍게 하기 위한 실험입니다. .NET의 액션을 작성하는 프로세스를 제어하는 것은 불가능합니다. 그렇기 때문에 IFiberAction 인터페이스를 구현하는 클래스의 인스턴스를 가지는 표준 액션으로 치환하기로 결정했습니다. 이러한 클래스의 인스턴스는 오브젝트 풀에서 나왔다가 완료되면 즉시 다시 돌아간다고 예상했습니다. 이렇게 하면 GC가 줄어듭니다.

IFiberAction 인터페이스는 다음과 같습니다. 

public interface IFiberAction 
{ 
   void Execute() 
   void Return() 
} 
Execute 메소드에는 정확히 실행이 필요한 것이 포함되어 있습니다.
Return 메소드는 Execute 후, 풀에 오브젝트를 돌려놓는 타이밍에서 호출됩니다.

예: 

public class PeerHandleRequestAction : IFiberAction 
{ 
   public static readonly ObjectPool Pool = initialization;
   public OperationRequest Request {get; set;} 
   public PhotonPeer Peer {get; set;} 
   public void Execute() 
   { 
      this.Peer.HandleRequest(this.Request); 
   } 

   public void Return() 
   { 
      this.Peer = null; 
      this.Request = null; 
      Pool.Return(this); 
   } 
} 

//아래와 같이 변경했습니다 
var action = PeerHandleRequestAction.Pool.Get(); 
action.Peer = peer; 
action.Request = request; 

peer.Fiber.Enqueue(action);


마지막으로

간단히 요약해 보겠습니다. Photon에서의 확실한 스레드 안전(thread-safety)을 위해, 태스크 큐(여기서는 파이버로 대표됨)을 사용하고 있습니다. 사용하는 파이버의 주된 타입은 PoolFiber와 이것을 확장하는 클래스입니다. PoolFiber 은, 표준 .NET 스레드 풀의 태스크 큐를 구현합니다. PoolFiber의 소규모 퍼포먼스 풋프린트에 의해, 이것이 필요한 곳에 독자적인 파이버를 넣을 수 있습니다. 태스크 큐를 보류할 필요가 있는 경우에는 ExtendedPoolFiber를 사용합니다.

IExecutor 인터페이스를 구현하는 executor는 파이버 내에서 직접 태스크를 실행합니다. DefaultExecutor는 누구에게도 도움이 되는 것이지만, 예외의 경우에는 실행하기 위해 패스된 나머지 태스크를 모두 잃을 수도 있습니다. 이 점에서는 FailSafeExecutor가 타당한 선택을 했다고 할 수 있습니다. executor가 태스크의 배치를 실행하는 전후에 어떠한 액션을 실행할 필요가 있을 경우에는, BeforeAfterExecutor가 편리합니다.



출처 : Photon Blog by ExitGames(독일)