[ 섹션 0. 개론 ]
# 서버 OT
- 서버란 다른 컴퓨터에서 연결이 가능하도록 대기 상태로 상시 실행중인 프로그램이다.
- Web 서버 (aka. HTTP Server)
~> 테이크아웃 포장 전문 식당
~> 손님이 음식을 받아서 떠나면, 그 이후론 연락이 끊긴다.
~> 드물게 정보를 요청/갱신한다.
~> 식당에서 손님한테 먼저 접근할 일은 없다. (ex : 물 따라드릴까요?)
~> 주문 후 손님이 바로 떠나면, 손님의 상태를 당분간 잊고 지낸다. (Stateless)
~> Web 서버 제작은 처음부터 만드는 경우는 사실상 없고, Framework를 하나 골라서 사용한다.
~> 질의/응답 형태
- Game 서버 (aka. TCP Server, Binary Server, Stateful Server ...)
~> 일반 식당
~> 서빙 직원이 와서 손님에게 물어볼 수도 있고, 손님이 추가 주문을 하기도 한다.
~> 요청/갱신 횟수가 많다.
~> 언제라도 직원이 손님한테 접근이 가능해야 한다.
~> 손님이 식당에 머무는 동안, 손님의 상태를 보며 최상의 서비스를 제공한다. (Stateful)
~> 게임/장르에 따라 요구사항이 너무 달라 최적의 Framework라는 것이 존재하기 애매하다.
~> 실시간 Interaction이 존재
# 환경설정
- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 에서 [ 한 개의 시작 프로젝트 ] 가 아닌
[ 여러 개의 시작 프로젝트 ] 를 선택한 뒤 동시에 실행하고자하는 프로젝트의 작업 상태를 [ 없음 ] 에서 [ 시작 ] 으로
변경하면 작업 상태가 [ 시작 ] 으로 설정된 여러개의 프로젝트를 동시에 실행 가능하다.
[ 섹션 1. 멀티쓰레드 프로그래밍 ]
# 멀티쓰레드 개론
- Process는 운영체제로부터 자원을 할당받는 작업의 단위이다.
- Thread는 한 Process가 할당받은 자원을 이용하는 실행의 단위이다.
- MultiThread란 하나의 Process를 다수의 실행 단위로 구분하여, 자원을 공유하고 자원의 생성과 관리의 중복성을
최소화하여 수행 능력을 향상 시키는 것이다.
- 한 고급 레스토랑이 운영되기 위해서는 로봇 직원들이 필요하며 로봇 직원 각각에게는 업무가 주어진다. (계산, 서빙 ...)
~> 또한, 영혼의 개수가 한정적이기 때문에 동시에 작동시킬 수 있는 로봇 직원의 수 역시 제한된다.
- 위의 내용에서 고급 레스토랑은 Process, 로봇 직원은 Thread, 영혼은 CPU 코어에 해당한다.
- 영혼의 개수가 1개로 한정적이기 때문에 1명의 로봇 직원만 작동시킬 수 있다.
~> 만약, 해당 영혼을 4명의 직원에게 0.1초씩 빠르게 번갈아가면서 주입할 경우 어떻게 될까?
~> 우리의 눈에는 4명의 직원 모두 작동하는 것처럼 보일 것이다.
- 우리가 보기에는 다수의 Process가 동시에 동작되는 것처럼 보이지만, 실제로 컴퓨터는 운영체제의 Scheduling에 의해
Task를 아주 짧은 시간동안 번갈아가며 수행하는 것이다.
~> 이를 MultiTasking 이라고 한다.
- CPU 코어의 Clock Cycle 증가를 통해 컴퓨터의 성능 개선을 꿈꿨던 개발자들은 전력 문제와 발열 문제라는 한계에 도달
하게 되고, 이에 선택한 대안이 CPU 코어의 개수를 늘리는 것이다.
~> 이를 MultiProcessor 라고 한다.
- CPU 코어가 4개일 경우 동시에 실행할 수 있는 Thread는 4개이므로 CPU 코어의 수가 많다고 해서 Thread의 수를 늘려
봤자 한계가 존재한다. ( CPU 코어의 수와 Thread의 수를 최대한 맞춰주는 것이 중요 )
~> 또한, Thread의 수가 많으면 많을수록 MultiTasking 부하가 상당해진다.
- Thread 사용시 Thread 배치는 구성하기 나름이다.
- MultiProcess 는 데이터 영역, Stack 영역, Heap 영역을 Process 끼리 전부 공유하지 않는다.
- MultiThread 는 Stack 영역을 제외한 데이터 영역, Heap 영역은 Thread 끼리 전부 공유한다.
+ 추가 검색 (https://inpa.tistory.com/entry/%F0%9F%91%A9%E2%80%8D%F0%9F%92%BB-multi-process-multi-thread)
- MultiProcessor 와 MultiProcess 의 차이점
~> Processor는 CPU 코어를, Process는 프로그램의 실행 상태를 일컫는다.
~> MultiProcessor 는 여러개의 CPU 코어가 하나의 시스템에서 동시에 실행되는 것을 의미한다.
~> MultiProcess 는 하나의 프로그램에서 여러 개의 Process를 실행하는 것을 의미한다. ( ex : fork() )
+ 추가 검색 ( https://wooody92.github.io/os/%EB%A9%80%ED%8B%B0-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C/ )
- MultiProcess 와 MultiThread 의 차이점
1. MultiThread는 MultiProcess 보다 적은 메모리 공간을 차지하고, Context Switching 이 빠르다는 장점을 가진다.
~> 그러나 MultiThread의 단점으로는 동기화 문제와 1개의 Thread 장애가 전체 Thread 에게 영향을 줄 수 있다는 것이다.
2. MultiProcess는 1개의 Process가 죽더라도 다른 Process 에게 영향을 주지 않아 안정성이 높다는 장점을 가진다.
~> 그러나 MultiProcess의 단점으로는 MultiThread 보다 많은 메모리 공간과 CPU 시간을 차지한다는 것이다.
# 쓰레드 생성
- 고급 레스토랑에서 정직원을 고용하는 것은 상당히 부담스럽다. ( 인건비, 4대 보험 등 ... )
~> 인력 상담소에서 단기 알바를 구하는 것이 좋다.
- 즉, Thread를 생성하는 것은 상당한 부하를 가져다 준다.
~> C#은 사용 가능한 Thread를 할당 받아 사용할 수 있는 Thread Pool을 지원한다. ( 사용 후 반환 / 일꾼이 없으면 대기 )
Thread 응용using System; using System.Threading; // 추가해야 Thread 사용 가능 namespace ServerCore { class Program { static void MainThread() { for (int i = 0; i < 5; i++) Console.WriteLine("Hello Thread!"); } static void Main(string[] args) { Thread t = new Thread(MainThread); // 한명의 직원을 고용한 뒤 MainThread 라는 업무를 할당한 것 t.IsBackground = true; // Main 함수가 종료될 경우 해당 Thread도 종료 (기본값은 foreground) t.Name = "Test Thread"; // Thread 이름 설정 t.Start(); // Thread 실행 Console.WriteLine("Waiting for Thread"); t.Join(); // 해당 Thread가 종료될때까지 기다리는 것 Console.WriteLine("Hello World"); } } }
Thread Pool 응용using System; using System.Threading; namespace ServerCore { class Program { static void MainThread(object state) { for (int i = 0; i < 5; i++) Console.WriteLine("Hello Thread!"); } static void Main(string[] args) { // Thread를 최소 1개에서 최대 5개까지만 빌려줄 수 있도록 제한 ThreadPool.SetMinThreads(1, 1); ThreadPool.SetMaxThreads(5, 5); // 람다식을 통해 Pool에서 Thread를 빌린뒤 영원히 반환하지 않는 함수 생성 for (int i = 0; i < 5; i++) ThreadPool.QueueUserWorkItem((obj) => { while (true) { } }); // Thread Pool에 더이상 남아있는 Thread가 없어 실행 X ThreadPool.QueueUserWorkItem(MainThread); } } }
Task 응용using System; using System.Threading; using System.Threading.Tasks; // 추가해야 Task 사용 가능 namespace ServerCore { class Program { static void MainThread(object state) { for (int i = 0; i < 5; i++) Console.WriteLine("Hello Thread!"); } static void Main(string[] args) { // Thread를 최소 1개에서 최대 5개까지만 빌려줄 수 있도록 제한 ThreadPool.SetMinThreads(1, 1); ThreadPool.SetMaxThreads(5, 5); for (int i = 0; i < 5; i++) { // Task로 직원이 할 일감을 생성하여 던져주면, ThreadPool에 대기중인 Thread가 해당 일감을 실행 // Task 생성시 TaskCreationOptions.LongRunning 옵션을 설정할 경우 Thread를 따로 관리 Task t = new Task(() => { while (true) { } }, TaskCreationOptions.LongRunning); t.Start(); } // TaskCreationOptions.LongRunning 옵션을 통해 Thread를 따로 관리하므로 실행 가능 ThreadPool.QueueUserWorkItem(MainThread); } } }
# 컴파일러 최적화
- Debug 모드와 Release 모드의 가장 큰 차이점은 Debug 모드에서는 코드 최적화를 하지 않고, Release 모드에서는 코드
최적화를 한다는 것이다.
~> 따라서 Debug 모드에서는 잘 실행되던 코드가 Release 모드에서는 실행되지 않을 수 있다.
~> volatile Keyword를 통해 최적화를 금지시킬 수 있다.
volatile Keyword 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { // volatile Keyword를 통해 해당 변수에 대한 최적화를 금지시킨다. volatile static bool _stop = false; // 전역 변수는 모든 Thread들이 공유 static void ThreadMain() { Console.WriteLine("Thread 시작!"); // Debug 모드가 아닌 Release 모드에서는 코드 최적화로 인해 일어나지 않던 Bug들이 발생할 수 있다. while (_stop == false) { // 누군가가 stop 신호를 true로 바꿔주기를 기다린다. } // 위의 while문은 컴파일러 최적화시 아래의 코드와 같이 변경된다. ( 즉, 없던 Bug 발생 ) //if (_stop == false) //{ // while (true) // { // 무한 루프에 빠진다. // } //} Console.WriteLine("Thread 종료!"); } static void Main(string[] args) { Task t = new Task(ThreadMain); t.Start(); Thread.Sleep(1000); // 1초동안 일시정지 _stop = true; Console.WriteLine("Stop 호출!"); Console.WriteLine("종료 대기중"); t.Wait(); // 해당 Task가 종료될때까지 기다리는 것 ( Thread의 Join() 과 같은 기능 ) Console.WriteLine("종료 성공"); } } }
# 캐시 이론
- 직원이 서빙을 통해 주문을 받고 주방과 원격으로 연결된 주문 현황에 주문 내역을 입력할 경우 주방에서 조리를
시작한다. ( 이때 직원이 주문 현황을 입력하기 위한 동선 자체가 많이 길다고 가정 )
- 직원은 보다 더 효율적으로 움직이기 위해서 주문을 1개 받자마자 멀리 떨어진 주문 현황에 주문 내역을 입력하러
가는 것이 아닌, 단기 기억/미니 수첩/대형 수첩 을 활용하여 주문들을 전부 기록해둔 뒤 나중에 한꺼번에 입력을 한다.
~> 만약 손님이 주문은 번복할 경우 본인이 수첩에 기록해둔 주문만 수정하면 되기 때문에 보다 더 효율적이다.
- 그러나 이러한 방법이 MultiThread 에서는 골치아플 수 있다.
~> 직원 1이 2번 테이블에 대한 콜라 주문을 받고, 2번 테이블 손님들이 직원 2에게 콜라가 아닌 사이다를 달라고
주문을 번복할 경우 혼란이 발생한다.
~> 직원 2 입장에서는 본인이 2번 테이블에 대해 콜라 주문을 받지도 않았고, 확인하고자 주문 현황을 확인해봐도
2번 테이블이 콜라를 주문했다는 사실을 알 수 없다.
( 2번 테이블에 대한 콜라 주문은 아직까지는 직원 1의 미니 수첩에 기록됨 )
- 위의 내용에서 로봇 직원은 Thread, 단기 기억/미니 수첩/대형 수첩이 Cache, 주문 현황은 Main Memory에 해당한다.
- 캐시 철학으로 시간적 지역성, 공간적 지역성이 있다.
~> 시간적 지역성은 최근에 참조된 주소의 내용이 다시 참조될 가능성이 높다는 것이다.
~> 공간적 지역성은 기억장치 내에 서로 인접하여 저장되어 있는 데이터들이 연속적으로 접근될 가능성이 높다는 것이다.
캐시 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static void Main(string[] args) { int[,] arr = new int[10000, 10000]; { long start = DateTime.Now.Ticks; for (int x = 0; x < 10000; x++) for (int y = 0; y < 10000; y++) arr[x, y] = 1; long end = DateTime.Now.Ticks; Console.WriteLine($"(x, y) 순서 걸린 시간 {end - start}"); // 결과 : 3138744 } { long start = DateTime.Now.Ticks; for (int x = 0; x < 10000; x++) for (int y = 0; y < 10000; y++) arr[y, x] = 1; long end = DateTime.Now.Ticks; Console.WriteLine($"(y, x) 순서 걸린 시간 {end - start}"); // 결과 : 4003940 } } } }
# 메모리 배리어
- 코드 최적화 뿐만이 아니라 HW 역시 최적화를 진행한다.
~> CPU에게 일련의 명령어들을 시킬 경우 CPU가 의존성이 없다고 판단되는 명령어들의 순서를 제멋대로 재배치한다.
~> Memory Barrier를 통해 코드 순서 재배치를 억제할 수 있다.
- Memory Barrier를 통해 가시성 또한 확보할 수 있다.
~> 이때의 가시성 확보는 MultiThread 환경에서 Thread의 Cache의 상황을 Main Memory에 반영하는 것을 말한다.
~> 따라서 Store 후에 1번, Load 전에 1번 해주는 것이 가시성 확보에 도움이 된다. ( 최신 정보 반영 및 사용 )
HW 최적화 예시using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static int x = 0; static int y = 0; static int result1 = 0; static int result2 = 0; static void Thread_1() { // CPU가 아래의 명령어들이 의존성이 없다고 판단하여 코드의 순서를 제멋대로 재배치 y = 1; // Store y result1 = x; // Load x } static void Thread_2() { // CPU가 아래의 명령어들이 의존성이 없다고 판단하여 코드의 순서를 제멋대로 재배치 x = 1; // Store x result2 = y; // Load y } static void Main(string[] args) { int count = 0; while (true) { count++; x = y = result1 = result2 = 0; Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); // 2개의 Thread가 전부 끝날때까지 Main Thread는 대기 // CPU가 코드의 순서를 제멋대로 재배치하여 둘 다 0인 상황이 발생할 수 있었다. if (result1 == 0 && result2 == 0) break; } Console.WriteLine($"{count}번만에 빠져나옴!"); } } }
Memory Barrier 응용 ( 코드 순서 재배치 억제, 가시성 )using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { // 메모리 배리어 기능 // A) 코드 순서 재배치 억제 // B) 가시성 확보 (MultiThread 환경에서 Thread의 Cache의 상황을 Main Memory에 반영) // 메모리 배리어 종류 // 1) Full Memory Barrier (어셈블리어 : MFENCE, C# : Thread.MemoryBarrier) : Store/Load 둘 다 막는다. // 2) Store Memory Barrier (어셈블리어 : SFENCE) : Store 만 막는다. // 3) Load Memory Barrier (어셈블리어 : LFENCE) : Load 만 막는다. class Program { static int x = 0; static int y = 0; static int result1 = 0; static int result2 = 0; static void Thread_1() { y = 1; // Store y ( Main Memory에 y의 값을 반영 ) Thread.MemoryBarrier(); // 코드 순서 재배치 억제 result1 = x; // Load x ( Main Memory에 있는 x의 값을 반영 ) } static void Thread_2() { x = 1; // Store x ( Main Memory에 x의 값을 반영 ) Thread.MemoryBarrier(); // 코드 순서 재배치 억제 result2 = y; // Load y ( Main Memory에 있는 y의 값을 반영 ) } static void Main(string[] args) { int count = 0; while (true) { count++; x = y = result1 = result2 = 0; Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); // 2개의 Thread가 전부 끝날때까지 Main Thread는 대기 // CPU의 코드 재배치를 억제하였기 때문에 둘 다 0인 경우는 존재 X ~> 무한루프 if (result1 == 0 && result2 == 0) break; } Console.WriteLine($"{count}번만에 빠져나옴!"); } } }
# interlocked
- Race Condition (경합 조건) 이란 2개 이상의 Process 혹은 Thread가 공유 자원을 서로 사용하려고 경합(Race)하는
현상을 의미한다.
- MultiThread 환경에서는 Process 내의 모든 자원을 공유할 수 있다는 점에서 동기화 문제가 발생한다.
~> Interlocked 를 통해 해결이 가능하다. ( 메모리 동기화 구현 )
~> Interlocked 는 정수만 사용할 수 있다는 단점이 존재한다.
Race Condition 예시using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static int number = 0; static void Thread_1() { for (int i = 0; i < 100000; i++) number++; // number++은 어셈블리어에서 아래와 같이 동작한다. (즉, atomic operation이 X) // int temp = number; // temp += 1; // number = temp; } static void Thread_2() { for (int i = 0; i < 100000; i++) number--; // number--은 어셈블리어에서 아래와 같이 동작한다. (즉, atomic operation이 X) // int temp = number; // temp -= 1; // number = temp; } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); // 위의 주석 처리된 6줄의 코드 순서를 정확히 알지 못하기 때문에 결과 예측 X Console.WriteLine(number); } } }
Interlocked 를 통한 원자성 및 순서 보장using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static int number = 0; static void Thread_1() { for (int i = 0; i < 100000; i++) Interlocked.Increment(ref number); // 원자성 보장 및 순서 보장 (All or Nothing) } static void Thread_2() { for (int i = 0; i < 100000; i++) Interlocked.Decrement(ref number); // 원자성 보장 및 순서 보장 (All or Nothing) } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(number); } } }
# Lock 기초
- Critical Section (임계 영역) 은 2개 이상의 Thread가 동시에 접근할 수 없는 공유 자원을 사용하는 코드 영역을 의미한다.
- Mutual Exclusive (상호 배제) 란 특정 시점에서 공유 자원을 1개의 Thread 만이 사용할 수 있으며, 다른 Thread 들은
접근하지 못하도록 제어하는 방법을 의미한다.
Critical Section 및 Mutual Exclusive 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static int number = 0; static object _obj = new object(); static void Thread_1() { for (int i = 0; i < 100000; i++) { Monitor.Enter(_obj); // 문에 잠금장치 설정 (이미 잠겨있을 경우 대기) number++; Monitor.Exit(_obj); // 문에 잠금장치 해제 (생략할 경우 deadlock 발생 가능) } } static void Thread_2() { for (int i = 0; i < 100000; i++) { Monitor.Enter(_obj); // 문에 잠금장치 설정 (이미 잠겨있을 경우 대기) number--; Monitor.Exit(_obj); // 문에 잠금장치 해제 (생략할 경우 deadlock 발생 가능) } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(number); } } }
lock Keyword 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static int number = 0; static object _obj = new object(); static void Thread_1() { for (int i = 0; i < 100000; i++) { lock(_obj) // 내부는 Monitor.Enter 과 Monitor.Exit 로 구현됨 (자동으로 lock 해제) { number++; } } } static void Thread_2() { for (int i = 0; i < 100000; i++) { lock (_obj) // 내부는 Monitor.Enter 과 Monitor.Exit 로 구현됨 (자동으로 lock 해제) { number--; } } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(number); } } }
# DeadLock
- Dead Lock (교착 상태) 이란 2개 이상의 Process 또는 Thread가 Mutual Exclusive 으로 사용하던 자원을 요청하면서
서로가 가진 자원을 대기하는 현상을 말한다.
- Dead Lock을 try-catch문, Monitor.TryEnter()를 통해 예외를 처리하기보단 Crash 를 통해 오류를 수정하는 것이 더 좋다.
- Dead Lock은 개발 단계에서 잘 발생하지 않다가 출시 이후 User들이 몰리는 상황에 갑작스럽게 발생하는 경우가 많다.
Dead Lock 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class SessionManager { static object _lock = new object(); public static void TestSession() { lock (_lock) { } } public static void Test() { lock (_lock) { UserManager.TestUser(); } } } class UserManager { static object _lock = new object(); public static void TestUser() { lock (_lock) { } } public static void Test() { lock (_lock) { SessionManager.TestSession(); } } } class Program { static void Thread_1() { for (int i = 0; i < 10000; i++) { SessionManager.Test(); } } static void Thread_2() { for (int i = 0; i < 10000; i++) { UserManager.Test(); } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); } } }
# Lock 구현 이론
- Lock 요청시 이미 누가 Lock을 점유하고 있을 경우 이를 어떻게 처리하는지에 따라 성능이 결정된다.
1. 루프를 돌면서 계속 점유를 시도한다. ( SpinLock )
2. Thread가 자신의 소유권을 포기하고, 나중에 다시 lock을 요청한다. ( 랜덤성 / context switching 비용 발생 )
3. 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청한다. ( AutoResetEvent )
# SpinLock
오류가 발생하는 SpinLock 구현 (즉, 루프를 돌면서 계속 점유를 시도)using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class SpinLock { volatile bool _locked = false; // lock 잠금 상태 (해당 lock의 사용 유무) public void Acquire() // lock 설정 { // 오류가 발생하는 이유는 lock 잠금 상태 확인과 lock을 잠그는 것을 따로 하고 있기 때문 // 만약 2개의 Thread가 거의 동시다발적으로 들어와 동시에 while 문을 통과할 경우 문제 발생 // 따라서 아래의 lock 잠금 상태 확인과 lock을 잠그는 코드를 하나로 묶어줘야 한다. // 오류 발생 원인 시작 while (_locked) { // 잠금 상태가 풀릴때까지 무작정 기다린다. } _locked = true; // 오류 발생 원인 종료 } public void Release() // lock 해제 { _locked = false; } } class Program { static int _num = 0; static SpinLock _lock = new SpinLock(); static void Thread_1() { for (int i = 0; i < 100000; i++) { _lock.Acquire(); _num++; _lock.Release(); } } static void Thread_2() { for (int i = 0; i < 100000; i++) { _lock.Acquire(); _num--; _lock.Release(); } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(_num); } } }
정상적으로 동작하는 SpinLock 구현 (즉, 루프를 돌면서 계속 점유를 시도)
using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class SpinLock { volatile int _locked = 0; // lock 잠금 상태 (해당 lock의 사용 유무) public void Acquire() // lock 설정 { while (true) { // 방법 #1 // Interlocked.Exchange는 1번째 인자의 값을 2번째 인자의 값으로 변경하며, 반환값은 1번째 인자의 변경 전 값이다. int original = Interlocked.Exchange(ref _locked, 1); if (original == 0) // 즉, 아무도 lock을 사용하지 않는 상황에서 내가 lock을 건 경우 break; // 무작정 기다리는 것을 그만두겠다. (즉, lock 휙득 성공) // 방법 #2 // Interlocked.CompareExchange는 1, 3번째 인자의 값이 같다면 2번째 인자의 값을 1번째 인자에 대입하며, 반환값은 1번째 인자의 변경 전 값이다. // Interlocked.CompareExchange는 CAS (Compare-And-Swap) 연산 수행 int expected = 0; // 예상한 값 int desired = 1; // 기대한 값 int original2 = Interlocked.CompareExchange(ref _locked, desired, expected); if (original2 == 0) // 즉, 아무도 lock을 사용하지 않는 상황에서 내가 lock을 건 경우 break; // 무작정 기다리는 것을 그만두겠다. (즉, lock 휙득 성공) } } public void Release() // lock 해제 { _locked = 0; } } class Program { static int _num = 0; static SpinLock _lock = new SpinLock(); static void Thread_1() { for (int i = 0; i < 100000; i++) { _lock.Acquire(); _num++; _lock.Release(); } } static void Thread_2() { for (int i = 0; i < 100000; i++) { _lock.Acquire(); _num--; _lock.Release(); } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(_num); } } }
# Context Switching
- 루프를 돌면서 계속 점유를 시도하는 SpinLock과 달리, Sleep과 Yield를 통해 Thread가 자신의 소유권을 포기하고,
나중에 다시 lock을 요청하는 lock 구현 방식이 마냥 장점만 존재한다고는 할 수 없다.
~> Context Switching 비용 문제가 발생한다.
- Context Switching 이란 CPU가 어떤 Process 또는 Thread를 실행하고 있는 상태에서 운영체제의 Scheduling에 의해
더 높은 우선순위를 가진 Process 또는 Thread가 실행되어야 할 때, Register에 저장된 기존 Process 또는 Thread의 정보
를 Kernel에 저장하고 실행하고자 하는 Process 또는 Thread의 정보를 복원하는 일련의 과정을 말하며 이러한 과정은
lock 구현때뿐만이 아닌 늘상 발생하고 있는 자연스러운 현상이다.
Sleep과 Yield 를 통한 lock 구현 (즉, Thread가 자신의 소유권을 포기하고, 나중에 다시 lock을 요청)using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Lock { volatile int _locked = 0; // lock 잠금 상태 (해당 lock의 사용 유무) public void Acquire() // lock 설정 { while (true) { // 방법 #1 int original = Interlocked.Exchange(ref _locked, 1); if (original == 0) break; // 방법 #2 int expected = 0; int desired = 1; int original2 = Interlocked.CompareExchange(ref _locked, desired, expected); if (original2 == 0) break; // 잠시 쉬다 올게~ (3개의 방법중 1개 선택) Thread.Sleep(1); // 무조건 휴식 => 1ms 정도 쉬고 싶은데 정확한 시간은 운영체제가 결정 Thread.Sleep(0); // 조건부 양보 => 나보다 우선순위가 낮은 애들한테는 양보 X => 나보다 우선순위가 높거나 같은 애들이 없다면 남은 시간을 본인이 소진 Thread.Yield(); // 관대한 양보 => 관대하게 양보할테니, 지금 실행이 가능한 애가 있다면 실행하세요 => 실행 가능한 애가 없다면 남은 시간을 본인이 소진 } } public void Release() // lock 해제 { _locked = 0; } } class Program { static int _num = 0; static Lock _lock = new Lock(); static void Thread_1() { for (int i = 0; i < 100000; i++) { _lock.Acquire(); _num++; _lock.Release(); } } static void Thread_2() { for (int i = 0; i < 100000; i++) { _lock.Acquire(); _num--; _lock.Release(); } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(_num); } } }
# AutoResetEvent
AutoResetEvent를 통한 lock 구현 (즉, 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청)using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Lock { // bool <- Kernel AutoResetEvent _avaliable = new AutoResetEvent(true); // true일 경우 아무나 들어올 수 있는 상태, false일 경우 누구도 들어올 수 없는 상태 public void Acquire() // lock 설정 { _avaliable.WaitOne(); // 입장 시도 (true일 경우 입장이 가능하며, 자동으로 문을 닫아준다.) // _avaliable.Reset()은 bool의 값을 false로 변경 -> AutoResetEvent에서는 WaitOne() 내부에 포함되어 있다. } public void Release() // lock 해제 { _avaliable.Set(); // bool의 값을 true로 변경 } } class Program { static int _num = 0; static Lock _lock = new Lock(); static void Thread_1() { for (int i = 0; i < 1000; i++) { _lock.Acquire(); _num++; _lock.Release(); } } static void Thread_2() { for (int i = 0; i < 1000; i++) { _lock.Acquire(); _num--; _lock.Release(); } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(_num); } } }
오류가 발생하는 ManualResetEvent를 통한 lock 구현
(즉, 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청)using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Lock { // bool <- Kernel ManualResetEvent _avaliable = new ManualResetEvent(true); // true일 경우 아무나 들어올 수 있는 상태, false일 경우 누구도 들어올 수 없는 상태 public void Acquire() // lock 설정 { // 오류가 발생하는 이유는 입장 시도와 문을 닫는 것을 따로 하고 있기 때문 _avaliable.WaitOne(); // 입장 시도 (true일 경우 입장이 가능하며, 자동으로 문을 닫아주지 않는다.) _avaliable.Reset(); // bool의 값을 false로 변경 -> ManualResetEvent에서는 WaitOne() 내부에 포함되어 있지 않다. } public void Release() // lock 해제 { _avaliable.Set(); // bool의 값을 true로 변경 } } class Program { static int _num = 0; static Lock _lock = new Lock(); static void Thread_1() { for (int i = 0; i < 1000; i++) { _lock.Acquire(); _num++; _lock.Release(); } } static void Thread_2() { for (int i = 0; i < 1000; i++) { _lock.Acquire(); _num--; _lock.Release(); } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(_num); } } }
Mutex를 통한 lock 구현using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static int _num = 0; static Mutex _lock = new Mutex(); static void Thread_1() { for (int i = 0; i < 1000; i++) { _lock.WaitOne(); // 입장 시도 (입장 성공시 문을 잠근다.) _num++; _lock.ReleaseMutex(); // 나가면서 문의 잠금을 해제한다. } } static void Thread_2() { for (int i = 0; i < 1000; i++) { _lock.WaitOne(); // 입장 시도 (입장 성공시 문을 잠근다.) _num--; _lock.ReleaseMutex(); // 나가면서 문의 잠금을 해제한다. } } static void Main(string[] args) { Task t1 = new Task(Thread_1); Task t2 = new Task(Thread_2); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(_num); } } }
+ 추가 정리
- ManualResetEvent 사용 이유?
~> ManualResetEvent는 1개의 Thread만 통과시키고 문을 닫는 AutoResetEvent와 달리, 문이 1번 열리면 대기중이던
여러개의 Thread 를 동시에 수행하고자 할 때 유용하다.
- AutoResetEvent와 Mutex의 차이?
~> Mutex는 AutoResetEvent 보다 더 많은 정보를 가진다. (잠금 횟수, Thread ID 등...) 이에 따른 추가비용도 존재한다.
# ReaderWriterLock
ReaderWriterLock 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); // 예를 들어 게임에서 일일퀘스트 보상 지급시 코인, 경험치, 물약은 고정적인 보상이며 // 주말, 명절 이벤트로 일일 퀘스트 보상에 +a 정도의 보상이 추가로 지급된다고 가정하자. // 보상을 얻는 함수는 수없이 실행될거고, 보상을 추가하는 함수는 가끔 실행될 것이다. // 따라서 보상을 추가하는 함수의 lock은 실행될 확률이 1.0%, 실행되지 않을 확률이 99.0% 이다. // 이럴때 쓰는 lock이 ReaderWriterLock이다. (ReaderWriterLockSlim이 더 최신버전) class Reward { } static Reward GetRewardById(int id) { _lock.EnterReadLock(); // 아무도 WriteLock 을 잡고 있지 않은 경우에 마치 lock이 없는것처럼 Thread들이 동시다발적으로 들어올 수 있다. _lock.ExitReadLock(); return null; } void AddReward(Reward reward) { _lock.EnterWriteLock(); // 1번에 1개의 Thread만 휙득할 수 있다. _lock.ExitWriteLock(); } static void Main(string[] args) { } } }
+ 추가 검색 ( https://rito15.github.io/posts/06-cs-reader-writer-lock/ )
- Thread 간에 공유되는 데이터가 존재할 경우, 항상 모든 Thread가 해당 데이터를 읽고 쓰는 것은 아니다.
~> 어떤 Thread는 해당 데이터를 읽기만 하고, 어떤 Thread는 해당 데이터를 쓰기만 할 수도 있다.
~> 또한 다수의 읽기 Thread, 소수의 쓰기 Thread가 수행되는 경우도 존재한다.
~> 이럴때 일반적인 lock을 구현하여 읽기/쓰기가 수행되는 동안에 항상 lock을 설정/해제할 경우
데이터를 단순히 읽기만 하여 값이 변경되지 않는 상황에도 불필요하게 Critical Section을 만들게 되므로
성능 관점에서 손해다.
~> ReaderWriterlock 을 통해 해결이 가능하다.
- ReaderWriterlock은 데이터에 대한 쓰기 작업시에만 lock을 설정하고 읽기 작업시에는 lock을 설정하지 않는
비대칭적인 lock을 구현하여 성능 관점에서 이득을 취했다.
# ReaderWriterLock 구현 연습
재귀적 lock을 허용하지 않는 ReaderWriterLock 구현using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { // 재귀적 lock을 허용할지 (No) // Spinlock 정책 (5000번 Spin 후 Yield) class Lock { const int EMPTY_FLAG = 0x00000000; const int WRITE_MASK = 0X7FFF0000; // WRITE 부분만 추출하기 위한 MASK (AND 연산을 통한 추출) const int READ_MASK = 0x0000FFFF; // READ 부분만 추출하기 위한 MASK (AND 연산을 통한 추출) const int MAX_SPIN_COUNT = 5000; // [ Unused (1bit) ] [ WriteThreadId (15bit) ] [ ReadCount (16bit) ] ~> 32bit (int형) // Unused : 최상위 bit는 부호 bit이므로 음수가 될 가능성이 있어 사용 X / WriteThreadId : Write lock을 가진 Thread의 Id / ReadCount : 읽기 Thread의 총 수 int _flag = EMPTY_FLAG; public void WriteLock() { // 아무도 WriteLock of ReadLock을 휙득하고 있지 않을 때, 경합해서 소유권을 얻는다. int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK; // WriteThreadId (15bit)에는 본인의 Thread Id를 , WriteThreadId (15bit)를 제외한 나머지 bit는 전부 0으로 설정 while (true) { for (int i = 0; i < MAX_SPIN_COUNT; i++) { // 시도를 해서 성공하면 return if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG) return; } Thread.Yield(); } } public void WriteUnlock() { Interlocked.Exchange(ref _flag, EMPTY_FLAG); } public void ReadLock() { // 아무도 WriteLock을 휙득하고 있지 않을 때, ReadCount를 1 늘린다. while (true) { for (int i = 0; i < MAX_SPIN_COUNT; i++) { int expected = (_flag & READ_MASK); // READ_MASK와 AND 연산시 WRITE 부분도 0 -> 아무도 WriteLock을 휙득하고 있지 X if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected) return; // 위의 연산이 실패하는 경우 ? // #1. 누군가 WriteLock을 휙득하고 있는 경우 // #2. A와 B Thread가 거의 동시에 도착하여 expected의 값은 동일하게 얻지만, // A Thread가 먼저 CompareExchange 를 통해 expected의 값을 +1 할 경우 // B Thread가 CompareExchange 시 _flag의 값이 expected + 1 로 변경되어 요청이 실패한다. } Thread.Yield(); } } public void ReadUnlock() { Interlocked.Decrement(ref _flag); } } }
재귀적 lock을 허용하는 ReaderWriterLock 구현using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { // 재귀적 lock을 허용할지 (Yes) : WriteLock ~> WriteLock (OK), WriteLock ~> ReadLock (OK), ReadLock ~> WriteLock (Not OK) class Lock { const int EMPTY_FLAG = 0x00000000; const int WRITE_MASK = 0X7FFF0000; const int READ_MASK = 0x0000FFFF; const int MAX_SPIN_COUNT = 5000; int _flag = EMPTY_FLAG; int _writeCount = 0; // 재귀적으로 몇개의 Write를 할 것인지 public void WriteLock() { // 동일 Thread가 WriteLock을 이미 휙득하고 있는지 확인 int lockThreadId = (_flag & WRITE_MASK); if (Thread.CurrentThread.ManagedThreadId == lockThreadId) { _writeCount++; return; } // 아무도 WriteLock of ReadLock을 휙득하고 있지 않을 때, 경합해서 소유권을 얻는다. int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK; // WriteThreadId (15bit)에는 본인의 Thread Id를 , WriteThreadId (15bit)를 제외한 나머지 bit는 전부 0으로 설정 while (true) { for (int i = 0; i < MAX_SPIN_COUNT; i++) { // 시도를 해서 성공하면 return if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG) { _writeCount = 1; return; } } Thread.Yield(); } } public void WriteUnlock() { int lockCount = --_writeCount; if (lockCount == 0) Interlocked.Exchange(ref _flag, EMPTY_FLAG); } public void ReadLock() { // 동일 Thread가 WriteLock을 이미 휙득하고 있는지 확인 int lockThreadId = (_flag & WRITE_MASK); if (Thread.CurrentThread.ManagedThreadId == lockThreadId) { Interlocked.Increment(ref _flag); return; } // 아무도 WriteLock을 휙득하고 있지 않을 때, ReadCount를 1 늘린다. while (true) { for (int i = 0; i < MAX_SPIN_COUNT; i++) { int expected = (_flag & READ_MASK); // READ_MASK와 AND 연산시 WRITE 부분도 0 -> 아무도 WriteLock을 휙득하고 있지 X if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected) return; } Thread.Yield(); } } public void ReadUnlock() { Interlocked.Decrement(ref _flag); } } }
구현한 ReaderWriterLock 동작 결과 확인using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { static volatile int count = 0; static Lock _lock = new Lock(); static void Main(string[] args) { Task t1 = new Task(delegate () { for (int i = 0; i < 100000; i++) { _lock.WriteLock(); count++; _lock.WriteUnlock(); } }); Task t2 = new Task(delegate () { for (int i = 0; i < 100000; i++) { _lock.WriteLock(); count--; _lock.WriteUnlock(); } }); t1.Start(); t2.Start(); Task.WaitAll(t1, t2); Console.WriteLine(count); } } }
# Thread Local Storage
- MultiThread 환경에서 수많은 Thread가 하나의 자원에 접근하고자 할 때 lock은 상호 배타적인 개념이기 때문에
1번에 1개의 작업만을 처리할 수 있다. 이에 MultiThread 환경의 장점을 활용하지 못할뿐더러 하나의 Thread가 처리하는
속도보다 못할 수도 있다.
~> Thread Local Storage를 통해 해결이 가능하다.
- Thread Local Storage는 전역변수이지만, Thread 마다 고유하게 접근할 수 있는 전역 변수이다.
Thread Local Storage 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { // Thread 마다 고유하게 접근할 수 있는 전역변수 static ThreadLocal<string> ThreadName = new ThreadLocal<string>(); static void WhoAmI() { ThreadName.Value = $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; Thread.Sleep(1000); Console.WriteLine(ThreadName.Value); } static void Main(string[] args) { Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI); // 매개변수로 입력하는 Action 만큼 Task를 만들어준다. ThreadName.Dispose(); // 자원 해제 } } }
보다 성능을 개선한 Thread Local Storage 응용using System; using System.Threading; using System.Threading.Tasks; namespace ServerCore { class Program { // Thread 마다 고유하게 접근할 수 있는 전역변수 // new ThreadLocal의 인자로 Func delegate를 받을 수 있다. // 이를 통해 TLS가 새로 만들어질 경우 ThreadLocal.Value에 return 값을 넣어준 뒤 그대로 보관한다. static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; }); static void WhoAmI() { // IsValueCreated는 ThreadLocal.Value의 값이 초기화 된 경우 true, 그렇지 않을 경우 false를 반환한다. bool repeat = ThreadName.IsValueCreated; if (repeat) Console.WriteLine(ThreadName.Value + " (repeat)"); else Console.WriteLine(ThreadName.Value); } static void Main(string[] args) { ThreadPool.SetMinThreads(1, 1); ThreadPool.SetMaxThreads(3, 3); Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI); // 매개변수로 입력하는 Action 만큼 Task를 만들어준다. ThreadName.Dispose(); // 자원 해제 } } }
[ 섹션 2. 네트워크 프로그래밍 ]
# 네트워크 기초 이론
- 삼성 아파트 201호에 사는 사람이 삼성 아파트 102호에 사는 사람에게 택배를 보내고자 한다.
- 삼성 아파트 202호에 사는 사람이 현대 아파트 101호에 사는 사람에게 택배를 보내고자 한다.
~> 이때 본인이 직접 택배를 배송하는 것이 아닌 우선 경비실에 본인이 보내고자 하는 택배를 전달한다.
~> 경비실에서 보내는 사람과 받는 사람을 확인한 뒤, 만약 두 사람이 사는 아파트가 같다면 경비 아저씨가 직접
택배를 전달해주고, 같지 않다면 택배 배송센터로 해당 택배를 전달한다.
~> 택배 배송 센터에서 받는 사람이 거주하는 아파트의 경비실에 택배를 전달하면, 경비아저씨가 직접 택배를
전달해준다.
- 위의 내용에서 고객은 단말기, 경비실은 스위치, 택배 배송센터는 라우터, 아파트 단지는 네트워크에 해당한다.
# 통신 모델
- 게임의 규모, 장르, 개발자의 역량에 따라 Protocol을 선택하는 것이 중요하다.
~> MMO RPG는 주로 TCP, Latency가 중요한 FPS는 주로 UDP를 사용한다.
~> TCP는 느린 속도와 높은 신뢰성을 가지고, UDP는 빠른 속도와 낮은 신뢰성을 가진다.
# 소켓 프로그래밍 입문 #1
- 손님은 본인의 핸드폰을 통해 식당에 전화를 걸어 입장이 가능한지 물어보고, 만약 입장이 가능한 경우 본인의 대리인을
식당에 입장 시킨다. 손님은 대리인을 통해 식당과 대화가 가능하다.
- 식당은 우선 문지기를 고용하고, 해당 문지기에게 손님들의 입장 문의 전화를 받을 수 있는 식당 번호를 알려준다.
문지기는 손님 대리인을 통해 손님과 대화가 가능하다.
- 직원 고용 및 교육이 끝난 경우 식당은 영업 및 안내를 시작한다.
- 위의 내용에서 손님은 Client, 대리인은 Session, 식당은 Server, 문지기는 Listener 소켓, 문지기 교육은 Bind,
영업 시작은 Listen, 안내는 Accept에 해당한다.
# 소켓 프로그래밍 입문 #2
- 우선 Test를 위해 [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 를 다음과 같이 설정한다.
Server 코드 구현using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { class Program { static void Main(string[] args) { // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환 IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문) IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력 // 문지기 고용 Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); try { // 문지기 교육 listenSocket.Bind(endPoint); // 영업 시작 // backlog : 최대 대기 수 listenSocket.Listen(10); // backlog를 매개변수로 입력 while (true) { Console.WriteLine("Listening..."); // 손님을 입장시킨다 // Accept() 는 Blocking 함수이므로 Client가 입장하지 않을 경우 무한히 대기한다. Socket clientSocket = listenSocket.Accept(); // 이때의 clientSocket은 대리인에 해당한다. // 받는다 byte[] recvBuff = new byte[1024]; int recvBytes = clientSocket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환 string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력 Console.WriteLine($"[From Client] {recvData}"); // 보낸다 byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!"); clientSocket.Send(sendBuff); // 대리인을 통해 Client와 대화 // 쫓아낸다 clientSocket.Shutdown(SocketShutdown.Both); // 연결 해제를 미리 예고 clientSocket.Close(); } } catch (Exception e) { Console.WriteLine(e.ToString()); } } } }
Client 코드 구현using System; using System.Text; using System.Net; using System.Net.Sockets; namespace DummyClient { class Program { static void Main(string[] args) { // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환 IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문) IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력 // 휴대폰 준비 Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); try { // 입장 문의 socket.Connect(endPoint); Console.WriteLine($"Connected To {socket.RemoteEndPoint.ToString()}"); // RemoteEndPoint는 본인과 연결된 대상 // 보낸다 byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World"); socket.Send(sendBuff); // 받는다 byte[] recvBuff = new byte[1024]; int recvBytes = socket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환 string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력 Console.WriteLine($"[From Server] {recvData}"); // 나간다 socket.Shutdown(SocketShutdown.Both); // 연결 해제를 미리 예고 socket.Close(); } catch (Exception e) { Console.WriteLine(e.ToString()); } } } }
# Listener
- 위의 코드에서 Accept() 는 Blocking 함수이므로 Client가 입장하지 않을 경우 무한히 대기한다.
~> Accept() 뿐만이 아니라 Send(), Receive() 역시 Blocking 방식으로 구현 되었다.
~> 무수한 Client를 받기 위해 Blocking 함수를 채택하는 것은 바람직하지 않을 수 있다.
~> 이를 NonBlocking 방식으로 구현하여 해결할 수 있다.
- Blocking 과 NonBlocking 의 차이
~> 낚시로 비유를 하자면 Blocking 방식은 낚시대를 던져놓고, 물고기가 잡힐때까지 아무 것도 하지 않으면서 낚시대만
뜷어져라 쳐다보고 있는 것이다.
~> 그러나 NonBlocking 방식은 낚시대를 던져놓고, 던지자마자 입질이 올 경우 바로 낚시대를 끌어 올리지만
던진 그 순간에 바로 입질이 오지 않을 경우 본인이 할 다른 일들을 하고 있다가 입질이 올 경우 그제서야 돌아와
낚시대를 끌어올리는 것이다.
Accept를 NonBlocking 방식으로 구현하기 위한 Listener Class 생성using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { internal class Listener { Socket _listenSocket; Action<Socket> _onAcceptHandler; public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler) // Socket 함수의 매개변수를 위한 endPoint, _onAcceptHandler의 event 함수로 등록하기 위한 onAcceptHandler (delegate를 통해 함수를 인자로 받은 것) { // 문지기 고용 _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _onAcceptHandler += onAcceptHandler; // onAcceptHandler 함수가 _onAcceptHandler event를 구독 신청 // 문지기 교육 _listenSocket.Bind(endPoint); // 영업 시작 // backlog : 최대 대기 수 _listenSocket.Listen(10); // backlog를 매개변수로 입력 // SocketAsyncEventArgs 는 일꾼 같은 친구임 ( Async 특징상 우리가 당장 값을 추출할 수 없었으니 이런 저런 정보들을 이 친구를 통해 전달받는 것 ) SocketAsyncEventArgs args = new SocketAsyncEventArgs(); // 한번만 만들어주면 계속해서 재사용이 가능하다는 장점을 가진다. ( args 라는 event를 생성 ) // AcceptAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 입질이 올 경우 args가 OnAcceptCompleted 함수를 자동으로 호출) args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); // OnAcceptCompleted 메소드가 args Completed event를 구독 신청 // *** 낚시대를 던진다 *** RegisterAccept(args); // 최초 1번은 직접 등록해준다. } void RegisterAccept(SocketAsyncEventArgs args) // AcceptAsync 함수의 매개변수를 위한 args { args.AcceptSocket = null; // 초기화 하지 않을 경우 Crash 발생 bool pending = _listenSocket.AcceptAsync(args); // 비동기 Accept() 함수 // *** 낚시대를 던지자마자 물고기가 잡힌 경우 *** if (pending == false) // AcceptAsync를 실행하는 동시에 Client로부터 접속 요청이 와서 pending 없이 완료된 경우 OnAcceptCompleted(null, args); // 직접 OnAcceptCompleted 함수를 호출 // 만약 pending이 true인 경우 당장은 완료되지 않았지만 나중에라도 완료가 된다면 위의 args가 자동으로 본인을 구독 신청한 구독자들에게 알려준다. (OnAcceptCompleted 함수를 호출) } void OnAcceptCompleted(object sender, SocketAsyncEventArgs args) // call back 함수로 등록하기 위한 매개변수 sender와 args ( 즉, 형식을 맞춰주기 위한 것 ) { // *** 낚은 물고기를 빼낸다 *** if (args.SocketError == SocketError.Success) // Client로부터 접속 요청이 와서 Accept까지 성공적으로 마친 경우 { _onAcceptHandler.Invoke(args.AcceptSocket); // _onAcceptHandler가 본인을 구독 신청한 구독자들에게 알려준다. (onAcceptHandler 함수를 호출) (args.AcceptSocket 는 ClientSocket을 뱉어준다.) } else Console.WriteLine(args.SocketError.ToString()); // *** 다시 낚시대를 던진다 *** RegisterAccept(args); // 여기까지 도달한 경우 다음 Client를 위해서 다시 등록해주는 것 } } }
Accept를 NonBlocking 방식으로 구현하기 위한 Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { class Program { static Listener _listener = new Listener(); static void OnAcceptHandler(Socket clientSocket) // Client Accept가 완료된 경우 { try { // 받는다 byte[] recvBuff = new byte[1024]; int recvBytes = clientSocket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환 string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력 Console.WriteLine($"[From Client] {recvData}"); // 보낸다 byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!"); clientSocket.Send(sendBuff); // 쫓아낸다 clientSocket.Shutdown(SocketShutdown.Both); // 미리 예고 clientSocket.Close(); } catch (Exception e) { Console.WriteLine(e.ToString()); } } static void Main(string[] args) { // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환 IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문) IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력 // 손님을 입장시킨다. _listener.Init(endPoint, OnAcceptHandler); // 문지기에게 endPoint 전달, 혹시라도 누군가 접속 요청 후 Accept 완료시 call back을 받기 위해 OnAcceptHandler를 event 함수로 등록하고자 전달 Console.WriteLine("Listening..."); while (true) { } } } }
+ 추가 정리
# Session #1
Receive를 NonBlocking 방식으로 구현하기 위한 Session Class 생성using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { class Session { Socket _socket; int _disconnected = 0; // 끊김의 여부를 판단할 flag (MultiThread 환경에서 Disconnect() 를 2번 이상 호출하는 것을 방지) public void Start(Socket socket) { _socket = socket; SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs(); // ReceiveAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 데이터가 올 경우 recvArgs가 OnRecvCompleted 함수를 자동으로 호출) recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted); // 데이터를 받기 위한 Buffer 설정 recvArgs.SetBuffer(new byte[1024], 0, 1024); RegisterRecv(recvArgs); } public void Send(byte[] sendBuff) { _socket.Send(sendBuff); } public void Disconnect() { if (Interlocked.Exchange(ref _disconnected, 1) == 1) return; _socket.Shutdown(SocketShutdown.Both); // 미리 예고 _socket.Close(); } void RegisterRecv(SocketAsyncEventArgs args) { bool pending = _socket.ReceiveAsync(args); // 비동기 Receive() 함수 if (pending == false) // ReceiveAsync를 실행하는 동시에 받고자 하는 데이터가 존재하여 pending 없이 완료된 경우 OnRecvCompleted(null, args); // 직접 OnRecvCompleted 함수를 호출 } void OnRecvCompleted(object sender, SocketAsyncEventArgs args) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터를 성공적으로 받은 경우 { try { string recvData = Encoding.UTF8.GetString(args.Buffer, args.Offset, args.BytesTransferred); Console.WriteLine($"[From Client] {recvData}"); RegisterRecv(args); } catch (Exception e) { Console.WriteLine($"OnRecvCompleted Failed {e}"); } } else { Disconnect(); } } } }
Receive를 NonBlocking 방식으로 구현하기 위한 Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { class Program { static Listener _listener = new Listener(); static void OnAcceptHandler(Socket clientSocket) // Client Accept가 완료된 경우 { try { // 받는다. Session session = new Session(); // Session Class 객체 생성 session.Start(clientSocket); // 보낸다. byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!"); session.Send(sendBuff); // 쫓아낸다. session.Disconnect(); } catch (Exception e) { Console.WriteLine(e.ToString()); } } static void Main(string[] args) { // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환 IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문) IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력 // 손님을 입장시킨다. _listener.Init(endPoint, OnAcceptHandler); // 문지기에게 endPoint 전달, 혹시라도 누군가 접속 요청 후 Accept 완료시 call back을 받기 위해 OnAcceptHandler를 event 함수로 등록하고자 전달 Console.WriteLine("Listening..."); while (true) { } } } }
# Session #2
- Send는 시점이 정해져 있지 않다.
~> Send시 보내줄 Buffer와 Msg를 같이 설정해줘야 하는데 미래에 어떤 Msg를 보낼지 모르기 때문에
Receive와 같은 방식으로 접근할 수 없다.
- 또한 Send는 OnSendCompleted() 호출 후 RegisterSend(sendArgs) 를 통해 다시 등록하는 것이 불가능하다.
~> 이는 똑같은 정보를 다시 보내는 것이기 때문이며, 따라서 재사용이 불가능하다.
- 마지막으로 MultiThread 환경에서 동시다발적으로 Send() 를 호출시 매번 RegisterSend() 가 호출되어 SendAsync를
매번 호출하게 되는데 이는 네트워크 과부화를 유발한다.
~> 이는 운영체제가 Kernel에서 처리하기 때문이다.
- 이를 바탕으로 Send는 다음과 같이 구현할 예정이다.
~> _sendArgs를 전역변수로 선언하여 재사용이 가능하도록 만들 것이다.
~> SendAsync를 매번 호출하는 것이 아닌 _sendQueue에 데이터가 어느정도 모일 경우 호출할 것이다.
Send를 NonBlocking 방식으로 구현하기 위한 Session Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { class Session { // ... object _lock = new object(); Queue<byte[]> _sendQueue = new Queue<byte[]>(); bool _pending = false; // pending 여부 판단 (true인 경우 누군가 보내고 있는 것, false인 경우 아무도 보내고 있지 않은 것) SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs(); // Send는 언제 호출될지 모르기 때문에 전역 변수로 선언 public void Start(Socket socket) { // ... // SendAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 데이터를 보낼 경우 _sendArgs가 OnSendCompleted 함수를 자동으로 호출) _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted); // ... } public void Send(byte[] sendBuff) { lock (_lock) { // 누군가 RegisterSend()를 하고 있어 SendAsync() 가 끝난 뒤 OnSendCompleted() 가 완료되기 전까지는 보내지않고 sendBuff를 Queue에 넣고 종료 // ~> 즉, 누군가 보내고 있으면 _pending 은 true이므로 본인은 보내지 않고 Queue에 집어넣기만 하는 것 _sendQueue.Enqueue(sendBuff); if (_pending == false) // RegisterSend() 를 가장 먼저 호출하여 전송까지 할 수 있는 상태인 경우 RegisterSend(); } } public void Disconnect() { if (Interlocked.Exchange(ref _disconnected, 1) == 1) return; _socket.Shutdown(SocketShutdown.Both); // 미리 예고 _socket.Close(); } void RegisterSend() { _pending = true; byte[] buff = _sendQueue.Dequeue(); _sendArgs.SetBuffer(buff, 0, buff.Length); bool pending = _socket.SendAsync(_sendArgs); // 비동기 Send() 함수 if (pending == false) // SendAsync를 실행하는 동시에 받고자 하는 데이터가 존재하여 pending 없이 완료된 경우 OnSendCompleted(null, _sendArgs); } void OnSendCompleted(object sender, SocketAsyncEventArgs args) { lock (_lock) // 오직 RegisterSend() 를 통해서만 OnSendCompleted() 가 호출되는 경우에는 필요 X (그러나, 우린 call back을 통해서도 호출 O) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터를 성공적으로 보낸 경우 { try { // _pending이 false인 상황에서 A Thread가 Send()시 RegisterSend() 를 호출하게 되는데 // 만약 pending이 true여서 OnSendCompleted() 가 바로 호출되는 것이 아닌 call back을 통해 나중에 호출될 경우를 가정하자. // 이러한 상황에서 만약 다른 Thread들이 Send()시 _pending이 true이기 때문에 RegisterSend() 를 호출하지 않고 Queue에 집어넣기만 한다. // 이때 A Thread가 OnSendCompleted() 호출시 본인 데이터뿐만이 아닌 Queue에 있는 데이터들까지 처리해준다. // ~> 즉, 가장 첫번째 예약 대기자가 본인의 예약을 기다리는 동안, 그 후에 예약을 건 사람들의 몫까지 처리해주는 것이다. if (_sendQueue.Count > 0) // Queue에 보내야 할 데이터가 남아있는 경우 RegisterSend(); // 다음 전송을 위해 RegisterSend() 호출 else // Queue에 보내야 할 데이터가 남아있지 않은 경우 _pending = false; } catch (Exception e) { Console.WriteLine($"OnSendCompleted Failed {e}"); } } else { Disconnect(); } } } // ... } }
+ 추가 정리
- 어차피 OnSendCompleted()에서 _sendQueue.Count가 0보다 큰 경우 RegisterSend()를 계속해서 호출하게 되는데
그렇게 되면 결국에는 성능적인 관점에서 달라진 것은 거의 없다고 볼 수 있다.
~> 100명의 사람이 Send() 를 호출시 SendAsync() 도 언젠가는 100번 호출 되기 때문이다.
~> 즉, 위의 코드가 완전한 해결책이라고는 보기 어렵다.
~> 따라서 위의 코드는 1차적으로 Send() 를 Async 계열로 바꿨다는 것에 의의를 가진다.
# Session #3
- 위의 코드에서 아쉬운 점은 _sendQueue에 있는 데이터 1개당 SendAsync() 를 1번씩 호출하고 있다는 것이다.
~> BufferList를 통해 해결이 가능하다.
BufferList를 통해 NonBlocking 방식의 Send를 개선하기 위한 Session Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { class Session { // ... // _pendingList 사용시 _pending 변수도 필요 X SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs(); SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs(); // Receive도 Send와 마찬가지로 전역변수로 선언해도 무방 List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 대기중인 목록들을 담기 위한 List // ... public void Send(byte[] sendBuff) { lock (_lock) { _sendQueue.Enqueue(sendBuff); // _pending 변수 대신 _pendingList.Count 사용 if (_pendingList.Count == 0) // RegisterSend() 를 가장 먼저 호출하여 전송까지 할 수 있는 상태인 경우 RegisterSend(); } } // ... void RegisterSend() // 해당 함수로 들어오는 시점에 _pendingList 는 언제나 null { // _sendQueue의 데이터들을 List로 연결하여 담아주게 되면 이를 SendAsync() 에 한번에 보낼 수 있다. // ~> 즉 _sendQueue에 있는 데이터 1개당 SendAsync() 를 1번씩 호출하는 것을 보완 while (_sendQueue.Count > 0) { byte[] buff = _sendQueue.Dequeue(); // _sendArgs.BufferList에 직접 Add할 경우 오류 발생 _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length)); // ArraySegment 는 어떤 배열의 일부를 나타내는 구조체이다. } _sendArgs.BufferList = _pendingList; bool pending = _socket.SendAsync(_sendArgs); if (pending == false) OnSendCompleted(null, _sendArgs); } void OnSendCompleted(object sender, SocketAsyncEventArgs args) { lock (_lock) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터들을 성공적으로 보낸 경우 { try { // 초기화 _sendArgs.BufferList = null; _pendingList.Clear(); if (_sendQueue.Count > 0) RegisterSend(); } catch (Exception e) { Console.WriteLine($"OnSendCompleted Failed {e}"); } } else { Disconnect(); } } } // ... } }
+ 추가 정리
- 위의 코드 역시 완벽하다고 할 수 없다.
~> RegisterSend() 에서 _sendQueue를 무조건 비워 모든 정보를 한번에 보내고 있는데, 일정 짧은 시간 동안 몇 Byte를
보냈는지 추적하여 너무 과하게 보낸다 싶으면 쉬어 가며 보내는 것이 성능적으로 더 좋다.
~> 또한 상대방이 악의적으로 의미없는 정보를 담은 다수의 패킷을 보내는 경우 이를 체크하여 비정상적이다 싶으면
받지 않도록 하는 것이 좋다.
~> 또한 때에 따라서는 패킷 자체를 1번에 작은 단위로 보내는 것이 아닌 뭉쳐 보내야 하는 경우도 존재한다.
# Session #4
- 추후를 위해 Engine과 Content를 분리하고자 한다.
~> event handler 를 통한 방법과 상속을 통한 방법이 존재한다.
~> 상속을 통한 방법이 더 간단하기 때문에 Session을 상속 받은 GameSession Class를 생성하여
Engine과 Content를 분리해 볼 것이다.
- Content 에서는 Session을 상속받은 GameSession을 생성하여 실행하고자 하는 코드들을 추가하고,
Engine 에서는 GameSession 내의 함수들이 무엇을 실행하는지는 잘 모르겠지만 적절한 타이밍에 해당 함수들을
호출만 해주는 것이다.
Engine과 Content를 분리하기 위한 Session Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { abstract class Session { // ... // Session을 상속받은 Class에서 구현하도록 abstract로 선언 // Engine에서 구현하는 것이 아닌 Content에서 구현하는 것이다. // Engine에서는 해당 함수를 호출만 한다. public abstract void OnConnected(EndPoint endPoint); // OnConnected() 함수는 엄밀히 말하면 Listener Class 에서 호출 (즉, Session Class 에서는 호출 X) public abstract void OnRecv(ArraySegment<byte> buffer); public abstract void OnSend(int numOfBytes); public abstract void OnDisconnected(EndPoint endPoint); // ... public void Disconnect() { if (Interlocked.Exchange(ref _disconnected, 1) == 1) return; OnDisconnected(_socket.RemoteEndPoint); // OnDisconnected() 함수 호출 (RemoteEndPoint는 본인과 연결된 대상) _socket.Shutdown(SocketShutdown.Both); _socket.Close(); } // ... void OnSendCompleted(object sender, SocketAsyncEventArgs args) { lock (_lock) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) { try { _sendArgs.BufferList = null; _pendingList.Clear(); OnSend(_sendArgs.BytesTransferred); // OnSend() 함수 호출 if (_sendQueue.Count > 0) RegisterSend(); } catch (Exception e) { Console.WriteLine($"OnSendCompleted Failed {e}"); } } else { Disconnect(); } } } // ... void OnRecvCompleted(object sender, SocketAsyncEventArgs args) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) { try { OnRecv(new ArraySegment<byte>(args.Buffer, args.Offset, args.BytesTransferred)); // OnRecv() 함수 호출 RegisterRecv(); } catch (Exception e) { Console.WriteLine($"OnRecvCompleted Failed {e}"); } } else { Disconnect(); } } } }
Engine과 Content를 분리하기 위한 Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using static System.Collections.Specialized.BitVector32; namespace ServerCore { class GameSession : Session // Session Class를 상속 받은 GameSession Class 생성 { public override void OnConnected(EndPoint endPoint) // OnConnected() 함수 override { Console.WriteLine($"OnConnected : {endPoint}"); // 보낸다. byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!"); Send(sendBuff); Thread.Sleep(1000); // 쫓아낸다. Disconnect(); } public override void OnDisconnected(EndPoint endPoint) // OnDisconnected() 함수 override { Console.WriteLine($"OnDisconnected : {endPoint}"); } public override void OnRecv(ArraySegment<byte> buffer) // OnRecv() 함수 override { string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count); Console.WriteLine($"[From Client] {recvData}"); } public override void OnSend(int numOfBytes) // OnSend() 함수 override { Console.WriteLine($"Transferred bytes : {numOfBytes}"); } } class Program { static Listener _listener = new Listener(); // 기존에는 OnAcceptHandler() 를 call back 하는 방식이였다. // ~> 잘못된건 아니지만 Session Class 를 상속받은 다양한 Session 개념 등장으로 수정 // ~> OnAcceptHandler() 함수 내의 session.Start(clientSocket) 는 Content 보단 Engine 에 있는것이 적절 static void Main(string[] args) { string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // 손님을 입장시킨다. // 따라서 어떤 Session을 만들지를 Init() 매개변수로 전달하면 Engine에서 이를 처리해주는 방식으로 수정 _listener.Init(endPoint, () => { return new GameSession(); }); // Func<Session> 을 Lambda 식으로 작성 Console.WriteLine("Listening..."); while (true) { } } } }
Engine과 Content를 분리하기 위한 Listener Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { internal class Listener { Socket _listenSocket; Func<Session> _sessionFactory; // Session을 어떤 방식으로, 어떤 Session 만들 것인지 // GameSession Class 는 Content 쪽에 존재하기 때문에 // Listenr와 Session과 같이 Engine 쪽에서 new 를 통해 생성하지 않도록 // Init()의 delegate 매개변수를 통해 어떤 Session을 만들지에 대한 함수를 인자로 받은 뒤 // 해당 함수를 _sessionFactory event 구독 신청을 통해 실행되도록 수정 public void Init(IPEndPoint endPoint, Func<Session> sessionFactory) { // 문지기 고용 _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _sessionFactory += sessionFactory; // sessionFactory 함수가 _sessionFactory event를 구독 신청 // 문지기 교육 _listenSocket.Bind(endPoint); // 영업 시작 // backlog : 최대 대기 수 _listenSocket.Listen(10); SocketAsyncEventArgs args = new SocketAsyncEventArgs(); args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); RegisterAccept(args); } void RegisterAccept(SocketAsyncEventArgs args) { args.AcceptSocket = null; bool pending = _listenSocket.AcceptAsync(args); if (pending == false) OnAcceptCompleted(null, args); } void OnAcceptCompleted(object sender, SocketAsyncEventArgs args) { if (args.SocketError == SocketError.Success) { // 받는다. Session session = _sessionFactory.Invoke(); // _sessionFactory 를 통한 GameSession 생성 (Content 딴에서 요구한 방식대로 Session 생성) session.Start(args.AcceptSocket); session.OnConnected(args.AcceptSocket.RemoteEndPoint); } else Console.WriteLine(args.SocketError.ToString()); RegisterAccept(args); } } }
# Connector
- 현재 DummyClient 에서 Connect() 는 Blocking 함수이다.
~> 게임에서 Blocking 함수 사용은 지양해야하므로 이를 NonBlocking 방식으로 구현하여 해결할 수 있다.
- 분산 Server 구현시 Server 끼리 통신하기 위해서는 한쪽은 Listener의 역할을, 한쪽은 Connector의 역할을 해야만 한다.
Connect를 NonBlocking 방식으로 구현하기 위한 Connector Class 생성using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; namespace ServerCore { class Connector { Func<Session> _sessionFactory; // Session을 어떤 방식으로, 어떤 Session 만들 것인지 public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory) { // 휴대폰 설정 Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _sessionFactory = sessionFactory; SocketAsyncEventArgs args = new SocketAsyncEventArgs(); args.Completed += OnConnectCompleted; args.RemoteEndPoint = endPoint; // 연결하기 위해 상대방의 주소를 넘겨준다. args.UserToken = socket; // UserToken을 통해 원하는 정보를 넘겨줄 수 있다. // socket을 전역변수로 선언하는 것이 아닌 UserToken을 통해 정보를 넘겨주는 이유는 // Listener 가 계속해서 뺑뺑이를 돌며 여러명을 받을 수 있듯이 // Connector 역시 여러명을 받을 수 있기 때문이다. RegisterConnect(args); } void RegisterConnect(SocketAsyncEventArgs args) { Socket socket = args.UserToken as Socket; if (socket == null) return; bool pending = socket.ConnectAsync(args); if (pending == false) OnConnectCompleted(null, args); } void OnConnectCompleted(object sender, SocketAsyncEventArgs args) { if (args.SocketError == SocketError.Success) { Session session = _sessionFactory.Invoke(); // _sessionFactory 를 통한 Session 생성 (Content 딴에서 요구한 방식대로 Session 생성) session.Start(args.ConnectSocket); session.OnConnected(args.RemoteEndPoint); } else { Console.WriteLine($"OnConnectCompleted Fail: {args.SocketError}"); } } } }
- 현재 Connector를 DummyClient 에서 사용할 수 없다. (Connector는 ServerCore의 Class)
~> 그렇다고 ServerCore의 Connector, Listener, Session Class를 복사하여 DummyClient 에 붙여넣는 것은 세련되지 X
- 추후에 DummyClient, Server, ServerCore 를 동시에 사용하게 된다.
~> 이때 ServerCore 는 실제로 실행되는 것이 아닌 라이브러리로만 사용할 예정이다.
- ServerCore를 라이브러리로 세팅한 뒤 DummyClient와 Server는 ServerCore 라이브러리를 참조하도록 설정한다.
- 우선 [ ServerCore 프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 애플리케이션 ] - [ 일반 ] 에서 출력 유형을
[ 콘솔 애플리케이션 ] 이 아닌 [ 클래스 라이브러리 ] 를 선택한다.
~> 클래스 라이브러리 선택시 해당 프로젝트는 독립적으로 실행할 수 없다.
~> 즉, 다른 프로젝트에 기생하여 간접적으로 실행될 수 있다.
- 그 후 [ DummyClient / Server 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 프로젝트 참조 ] 의 [ 프로젝트 ] 에서 ServerCore
를 선택한다.
~> 이를 통해 DummyClient 와 Server 프로젝트는 ServerCore 라이브러리를 참조한다.
더이상 ServerCore의 Program Class 는 사용하지 않으므로 해당 내용을 Server의 Program Class로 복사한 뒤 삭제
(Server 입장에서 ServerCore는 다른 프로젝트이므로 ServerCore의 Class들의 보호 수준을 public으로 변경)
(이를 통해 Server는 Content, ServerCore는 Engine의 역할을 맡은 것)using System; using System.Text; using System.Net; using System.Net.Sockets; using ServerCore; // Servercore 라이브러리 참조 namespace Server { class GameSession : Session { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); // 보낸다. byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!"); Send(sendBuff); Thread.Sleep(1000); // 쫓아낸다. Disconnect(); } public override void OnDisconnected(EndPoint endPoint) { Console.WriteLine($"OnDisconnected : {endPoint}"); } public override void OnRecv(ArraySegment<byte> buffer) { string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count); Console.WriteLine($"[From Client] {recvData}"); } public override void OnSend(int numOfBytes) { Console.WriteLine($"Transferred bytes : {numOfBytes}"); } } class Program { static Listener _listener = new Listener(); static void Main(string[] args) { string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // 손님을 입장시킨다. _listener.Init(endPoint, () => { return new GameSession(); }); Console.WriteLine("Listening..."); while (true) { } } } }
- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 에서 [ 한 개의 시작 프로젝트 ] 가 아닌
[ 여러 개의 시작 프로젝트 ] 를 선택한 뒤 DummyClient 와 Server 프로젝트의 작업 상태를 [ 없음 ] 에서 [ 시작 ] 으로,
ServerCore 프로젝트의 작업 상태를 [ 시작 ] 에서 [ 없음 ] 으로 변경한다.
Connector Class 사용을 위한 Client 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using ServerCore; // Servercore 라이브러리 참조 namespace DummyClient { // GameSession Class 추가 class GameSession : Session { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); // 보낸다 byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World"); Send(sendBuff); } public override void OnDisconnected(EndPoint endPoint) { Console.WriteLine($"OnDisconnected : {endPoint}"); } public override void OnRecv(ArraySegment<byte> buffer) { string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count); Console.WriteLine($"[From Server] {recvData}"); } public override void OnSend(int numOfBytes) { Console.WriteLine($"Transferred bytes : {numOfBytes}"); } } class Program { static void Main(string[] args) { string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // Connector Class 사용 Connector connector = new Connector(); connector.Connect(endPoint, () => { return new GameSession(); }); try { } catch (Exception e) { Console.WriteLine(e.ToString()); } } } }
# TCP vs UDP
- 게임에서는 패킷 단위로 통신이 이루어진다.
# RecvBuffer
- TCP 특성상 (일부만 보내는 흐름/혼잡제어) Client 에서 보낸 패킷이 100 Byte 라고 해서 100 Byte가 완전히 도착한다는
보장은 없다.
~> 100 Byte 미만의 패킷이 도착한 경우 이를 바로 처리할 수 없기 때문에 이를 recvBuffer에 보관만 하고 있다가
추후에 나머지 패킷이 마저 도착하면 이를 조립한 뒤 한번에 처리할 수 있도록 수정한다.
RecvBuffer 개선을 위한 RecvBuffer Class 생성using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ServerCore { public class RecvBuffer { // byte 배열을 들고 있어도 되지만 byte 를 들고 있는 이유? // ~> 엄청 큰 Byte 배열에서 부분적으로 잘라 사용하고 싶을 수도 있기 때문 ArraySegment<byte> _buffer; // _readPos와 _writePos는 Buffer에서 커서 역할을 한다. // Buffer의 크기는 8Byte, 1패킷이 3Byte 라고 가정했을때 아래의 예시를 살펴보자. // [r w] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (초기 상태) // [r] [ ] [ ] [w] [ ] [ ] [ ] [ ] (Client로부터 3Byte를 받은 상태) // [ ] [ ] [ ] [r w] [ ] [ ] [ ] [ ] (Content 코드에서 1패킷을 처리한 상태) int _readPos; int _writePos; // 생성자 public RecvBuffer(int bufferSize) { _buffer = new ArraySegment<byte>(new byte[bufferSize], 0, bufferSize); } public int DataSize { get { return _writePos - _readPos; } } public int FreeSize { get { return _buffer.Count - _writePos; } } public ArraySegment<byte> ReadSegment // 현재까지 받은 데이터의 유효 범위가 어디서부터 어디까지인가? { get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _readPos, DataSize); } } public ArraySegment<byte> WriteSegment // 다음 Recv시 빈 공간의 유효 범위가 어디서부터 어디까지인가? { get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _writePos, FreeSize); } } // Clean 함수를 통해 Buffer를 정리하지 않을 경우 [ ] [ ] [ ] [ ] [ ] [ ] [r] [w] 와 같이 _readPos와 _writePos가 Buffer의 끝까지 밀릴 수 있다. // _readPos와 _writePos의 위치가 다른 경우 _readPos 부터 _writePos - 1 까지의 데이터를 복사한 뒤 _writePos 를 이동시킨다. // _readPos와 _writePos의 위치가 같은 경우 데이터를 복사하지 않고 _readPos 와 _writePos 를 Buffer의 시작 위치로 이동시킨다. public void Clean() { int dataSize = DataSize; if (dataSize == 0) // _readPos와 _writePos의 위치가 같은 경우 (Client에서 보낸 모든 데이터를 처리한 상태) { _readPos = _writePos = 0; } else // _readPos와 _writePos의 위치가 다른 경우 { // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼? Array.Copy(_buffer.Array, _buffer.Offset + _readPos, _buffer.Array, _buffer.Offset, dataSize); _readPos = 0; _writePos = dataSize; } } public bool OnRead(int numOfBytes) // Content 코드에서 성공적으로 데이터를 가공한 경우 해당 함수를 통해 커서 위치를 이동시킨다. { if (numOfBytes > DataSize) return false; _readPos += numOfBytes; return true; } public bool OnWrite(int numOfBytes) // Client로부터 데이터를 받은 경우 해당 함수를 통해 커서 위치를 이동시킨다. { if (numOfBytes > FreeSize) return false; _writePos += numOfBytes; return true; } } }
RecvBuffer Class 생성에 따른 Session Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { public abstract class Session { // ... // RecvBuffer Class 사용 RecvBuffer _recvBuffer = new RecvBuffer(1024); // ... public abstract int OnRecv(ArraySegment<byte> buffer); // 반환값을 void에서 int로 수정 (얼마만큼의 데이터를 처리했는지 반환하도록) // ... public void Start(Socket socket) { _socket = socket; _recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted); // 아래 부분 삭제 // _recvArgs.SetBuffer(new byte[1024], 0, 1024); _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted); RegisterRecv(); } // ... void RegisterRecv() { // 아래 부분 추가 _recvBuffer.Clean(); ArraySegment<byte> segment = _recvBuffer.WriteSegment; _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count); bool pending = _socket.ReceiveAsync(_recvArgs); if (pending == false) OnRecvCompleted(null, _recvArgs); } void OnRecvCompleted(object sender, SocketAsyncEventArgs args) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) { try { // Write 커서 이동 if (_recvBuffer.OnWrite(args.BytesTransferred) == false) // BytesTransferred 는 수신 받은 Byte { Disconnect(); return; } // Content 쪽으로 데이터를 넘겨주고 얼마나 처리했는지 받는다. int processLen = OnRecv(_recvBuffer.ReadSegment); if (processLen < 0 | _recvBuffer.DataSize < processLen) { Disconnect(); return; } // Read 커서 이동 if (_recvBuffer.OnRead(processLen) == false) { Disconnect(); return; } RegisterRecv(); } catch (Exception e) { Console.WriteLine($"OnRecvCompleted Failed {e}"); } } else { Disconnect(); } } } }
Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using ServerCore; // Servercore 라이브러리 참조 namespace Server { class GameSession : Session { // ... // 반환값이 void 에서 int 로 수정됨에 따른 코드 수정 public override int OnRecv(ArraySegment<byte> buffer) { string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count); Console.WriteLine($"[From Client] {recvData}"); return buffer.Count; } // ... } // ... }
Client 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using ServerCore; // Servercore 라이브러리 참조 namespace DummyClient { class GameSession : Session { // ... // 반환값이 void 에서 int 로 수정됨에 따른 코드 수정 public override int OnRecv(ArraySegment<byte> buffer) { string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count); Console.WriteLine($"[From Server] {recvData}"); return buffer.Count; } // ... } // ... }
# SendBuffer
- Session 마다 자신의 고유 RecvBuffer를 가진다.
~> Client 가 보내는 정보는 각기 다 다르기 때문에 이는 당연한 사실이다.
- 그러나 sendBuffer는 Session 마다 고유하지 않고 보내는 순간에 외부에서 만들어진다.
~> 이는 성능적 이슈와 관련이 있다.
~> 만약 100명의 User가 같은 Zone 안에 있는 경우 User 1명이 이동시 해당 User의 이동 정보를 나머지 99명에게 전부
보내야 한다. 하지만 User 1명만 이동하는 것이 아니기 때문에 이동 패킷이 99 * 100 개가 전송되어야 한다.
- sendBuffer의 Size는 어떻게 결정해야 할까?
~> 만약 보내고자 하는 Class의 멤버 변수로 string 과 List 와 같이 가변적인 길이를 갖는다면 어떻게 해야 할까?
~> Thread 마다 자신만의 Chunk 를 크게 할당한 뒤 이를 계속해서 쪼개서 사용하도록 만든다.
SendBuffer 개선을 위한 SendBuffer Class 생성using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ServerCore { public class SendBufferHelper { // ThreadLocal : 본인의 Thread 에서만 사용 가능한 전역 변수 (Thread 끼리의 경합을 없애고자 사용) public static ThreadLocal<SendBuffer> CurrentBuffer = new ThreadLocal<SendBuffer>(() => { return null; }); public static int ChunkSize { get; set; } = 4096 * 100; public static ArraySegment<byte> Open(int reserveSize) { if (CurrentBuffer.Value == null) // 한번도 사용하지 않은 경우 CurrentBuffer.Value = new SendBuffer(ChunkSize); // 새로 생성 if (CurrentBuffer.Value.FreeSize < reserveSize) // 사용한 적은 있지만 요구한 Size보다 FreeSize가 더 작은 경우 CurrentBuffer.Value = new SendBuffer(ChunkSize); // 기존 Chunk 를 날린뒤 새로운 Chunk 로 교체 return CurrentBuffer.Value.Open(reserveSize); } public static ArraySegment<byte> Close(int usedSize) { return CurrentBuffer.Value.Close(usedSize); } } public class SendBuffer { byte[] _buffer; // _usedSize는 Buffer에서 커서 역할을 한다. // Buffer의 크기는 10Byte 라고 가정했을때 아래의 예시를 살펴보자. // [u] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (초기 상태) int _usedSize = 0; public int FreeSize { get { return _buffer.Length - _usedSize; } } // 생성자 public SendBuffer(int chunkSize) { _buffer = new byte[chunkSize]; } public ArraySegment<byte> Open(int reserveSize) // 최대 reserveSize 만큼의 Buffer 공간을 사용하고자 한다. { if (reserveSize > FreeSize) return null; return new ArraySegment<byte>(_buffer, _usedSize, reserveSize); } public ArraySegment<byte> Close(int usedSize) // 실제로 사용한 Buffer 공간을 반환하고자 한다. { ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize); _usedSize += usedSize; return segment; } // Send는 1명이 아닌 여러명에게 보내는 경우도 있기 때문에 본인이 Send 하였다고 해서 막바로 Clean 할 수 X // (옮기고자 하는 곳을 다른 누군가가 아직 참조중일 수도 있기 때문) // 따라서 SendBuffer는 Clean 하는 것이 아닌 1회용으로 사용한다. } }
SendBuffer Class 생성에 따른 Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using ServerCore; // Servercore 라이브러리 참조 namespace Server { class Knight { public int hp; public int attack; public string name; public List<int> skills = new List<int>(); } class GameSession : Session { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); Knight knight = new Knight() { hp = 100, attack = 10 }; ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); byte[] buffer = BitConverter.GetBytes(knight.hp); byte[] buffer2 = BitConverter.GetBytes(knight.attack); // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼? Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length); Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length); ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length); Send(sendBuff); Thread.Sleep(1000); // 쫓아낸다. Disconnect(); } // ... } // ... }
Session 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { public abstract class Session { // ... Queue<ArraySegment<byte>> _sendQueue = new Queue<ArraySegment<byte>>(); // Queue의 타입을 byte[] 에서 ArraySegment<byte> 로 수정 // ... // 매개변수의 타입을 byte[] 에서 ArraySegment<byte> 로 수정 public void Send(ArraySegment<byte> sendBuff) { lock (_lock) { _sendQueue.Enqueue(sendBuff); if (_pendingList.Count == 0) RegisterSend(); } } // ... void RegisterSend() { while (_sendQueue.Count > 0) { ArraySegment<byte> buff = _sendQueue.Dequeue(); // buff의 타입을 byte[] 에서 ArraySegment<byte> 로 수정 _pendingList.Add(buff); } _sendArgs.BufferList = _pendingList; bool pending = _socket.SendAsync(_sendArgs); if (pending == false) OnSendCompleted(null, _sendArgs); } // ... } }
# PacketSession
- 게임에서는 패킷 단위로 통신이 이루어진다.
~> 패킷 전용 OnRecv 를 만들 필요가 있다.
Session 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { // abstract class PacketSession 추가 public abstract class PacketSession : Session { public static readonly int HeaderSize = 2; // 패킷을 받은 경우 [ size (2Byte) ] [ packetId (2Byte) ] [ ... ] public sealed override int OnRecv(ArraySegment<byte> buffer) // sealed는 다른 Class가 PacketSession을 상속 받은 뒤 OnRecv를 override 하고자 할 경우 오류 발생 { int processLen = 0; // 내가 몇 Byte 를 처리했는가 while (true) // 패킷을 처리할 수 있을때까지 계속해서 반복 { // 최소한 Header 는 Parsing 할 수 있는지 확인 if (buffer.Count < HeaderSize) break; // 패킷이 완전체로 도착했는지 확인 ushort dataSize = BitConverter.ToUInt16(buffer.Array, buffer.Offset); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것 if (buffer.Count < dataSize) break; // 여기까지 왔으면 패킷 조립 가능 OnRecvPacket(new ArraySegment<byte>(buffer.Array, buffer.Offset, dataSize)); // 패킷의 유효 범위를 넘겨준다. processLen += dataSize; buffer = new ArraySegment<byte>(buffer.Array, buffer.Offset + dataSize, buffer.Count - dataSize); } return processLen; } public abstract void OnRecvPacket(ArraySegment<byte> buffer); } // ... }
Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다. { // Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다. public ushort size; // ushort는 2Byte public ushort packetId; // ushort는 2Byte } class GameSession : PacketSession // Session 이 아닌 PacketSession 을 상속 받도록 수정 { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); Packet packet = new Packet() { size = 100, packetId = 10 }; ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); byte[] buffer = BitConverter.GetBytes(packet.size); byte[] buffer2 = BitConverter.GetBytes(packet.packetId); // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼? Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length); Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length); ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length); Send(sendBuff); Thread.Sleep(1000); // 쫓아낸다. Disconnect(); } // abstract 함수인 OnRecvPacket override 는 필수 public override void OnRecvPacket(ArraySegment<byte> buffer) { ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것 ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + 2); Console.WriteLine($"RecvPacketID: {id}, Size: {size}"); } // Sealed 로 OnRecv 함수를 override 하는 것을 금지 시켰기 때문에 아래 부분 삭제 //public override int OnRecv(ArraySegment<byte> buffer) //{ // string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count); // Console.WriteLine($"[From Client] {recvData}"); // return buffer.Count; //} // ... } // ... }
Client 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using ServerCore; // Servercore 라이브러리 참조 namespace DummyClient { class Packet { public ushort size; // ushort는 2Byte public ushort packetId; // ushort는 2Byte } class GameSession : Session { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); Packet packet = new Packet() { size = 4, packetId = 7 }; for (int i = 0; i < 5; i++) { ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); byte[] buffer = BitConverter.GetBytes(packet.size); byte[] buffer2 = BitConverter.GetBytes(packet.packetId); // 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼? Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length); Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length); ArraySegment<byte> sendBuff = SendBufferHelper.Close(packet.size); Send(sendBuff); } } // ... } // ... }
# 서버 프레임워크 요약 정리
'Unity Engine Study > Unity 강의 #2' 카테고리의 다른 글
[Unity 강의 #2] 인프런 강의 - Part4: 게임 서버 (섹션 3 ~ 5) (0) | 2024.02.18 |
---|