내용 보기
작성자
관리자 (IP : 172.17.0.1)
날짜
2021-07-13 08:12
제목
[C#] 고성능 서버 - Thread Local Storage
프로그래밍에서 각 스레드별로 고유한 상태를 설정할 수 있는 공간을 Thread Local Storage (이하 TLS. transport layer security 아님) 라고 한다. VC++에서는 C#에도 이를 해결하는 방법과 주의해야 할 사항을 정리해본다.
async / await 을 절대 가볍게 접근하면 안된다주제와 약간 벗어날 수 있지만 서두에 미리 한 번 짚고 넘어갈 부분이 있다. 절대로 async / await를 이용한 비동기 프로그래밍을 만만하게 보아서는 안된다는 것이다. 나도 그랬지만 누구든지 제일 처음 비동기 메서드를 접했을 땐 이해하기 쉽고 간단한 기능이라는 첫인상을 가지게 될 것이다. 개인적으로는 비동기 메서드를 적용하고 난 후의 코드가 동기 프로그래밍과 너무 비슷해져 버리는 점이 착각을 유발하는 큰 원인이라고 생각한다 (MS: 얘는 뭐 좋게 해줘도 불만이 많네..) 이전에 DB 쿼리나 네트워크 통신같은 IO 작업에서 비동기로 받는 결과값을 처리하기 위해서는 하나의 동일한 주제(single concern)를 위한 로직임에도 불구하고 비동기 요청 이전과 이후의 코드가 분절되어야 했다. 이를테면 비동기 요청 전의 코드와 응답 후의 코드를 서로 다른 메서드로 나누어서 짜야 했다는 뜻이다. 코드의 가독성에 대해 고민을 좀 해봤던 개발자라면 람다를 써서 어떻게든 읽기 좋고 관리하기 좋도록 애써 보았을 수도 있으나, 가독성에서 정도의 차이가 있을 뿐 명백하게 존재하는 코드상의 분절을 피할 수 없었다. 비동기 메서드의 등장으로 이런 상황은 옛날 이야기가 되었다. 안간힘을 써보아도 완전하게 붙이기 힘들었던 분절된 코드들은 이제 하나의 async 함수 안에서 seamless하게 구현할 수 있게 되었다. 작성한 코드를 읽을 때에도 (신경써서 읽지 않는다면) 어디가 동기 처리이고, 어디가 비동기 처리인지도 잘 모르고 넘어갈만큼 술술 읽어내려가게 되었다. 좋게 해석하자면 어플리케이션 개발자가 좀 더 로직에만 집중 할 수 있는 환경이 되었다. 이것은 호수에 떠있는 백조와 같다. 일단 겉으로 보기에는 아주 우아하게 비동기 코드를 표현했으나, 조금만 안을 들여다보면 비동기 요청을 기준으로 발생하는 여전한 로직의 분절, 그에 따른 실행 시점 시간차 및 실행 환경상의 차이 등은 당연게도 여전히 존재하고 있기 때문이다. 이로 인한 이슈들은 동시성(concurrency)이 있는 멀티스레드 환경에서 더 잘 드러난다. MS는 실제로 프로그래머들이 하부의 복잡한 메커니즘을 잘 모르더라도 쉽고 편하게 비동기 로직을 다룰 수 있는 유토피아를 꿈꾸었을지 모르겠다. 하지만 싱글 스레드로 간단한 툴 한두개 짜는거면 몰라도… C#이란 언어로 고성능 서버를 만들겠다고 한다면, 이에 대한 충분한 이해가 없이는 런타임에서 예상못한 오작동을 피할 수 없을 것이다. 이후 글에서 언급할 내용도 비동기 함수의 실행 시점차와 관련되어 있으므로, 비동기 메서드에 대한 어느 정도의 이해가 필요하다. ThreadLocal우선 잠깐 언급했던 모든 tls 변수에 동일한 값의 복제본을 저장해 두려는 경우가 있다. 예를들어 스레드가 3개 있으면, 메모리 공간상에 각 스레드를 위한 변수 3개가 있고, 이들 모두에 같은 의미를 가지는 인스턴스를 하나씩 생성해 할당하는 경우를 말한다. 서로 다른 스레드끼리 공유해야 할 자원이 있을 때, 해당 자원에 lock이 없이 접근하고 싶다면 tls를 이용해 각 스레드마다 자원을 따로 만들어 각자 자기 리소스를 쓰게 하면 된다.
이런 경우는 비동기 메서드의 실행중 스레드의 교체가 발생하더라도 아무 문제가 되지 않는다. 어차피 어떤 스레드로 바뀌더라도 tls 변수가 하는 역할은 동일하기 때문이다. 0번 스레드가 불러다 쓰는 AsyncLocal문제는 스레드별로 tls의 상태가 서로 달라야 할 때 발생한다. 0번 스레드에는 tls에 “철수”가, 2번 스레드에는 “영희”가 적혀있어야 하고, 이를 사용해 스레드마다 다른 동작을 해야 하는 경우. 그런데 거기다 async/await를 이용한 비동기 프로그래밍을 함께 사용한 경우. 0번 철수 스레드가 코드 수행 도중 await 구문을 만나 task의 완료를 기다리고 있었지만, 대기가 풀렸을 때는 2번 스레드로 갈아타게 되면서 철수가 영희가 되버리는 경우다. 스레드별로 서로 다른 상태값을 사용해야 하는 예를 구승모 교수님의 Dispatcher 구현에서 찾아볼 수 있다. (ThreadLocal.h) Dispatcher는 고성능 멀티스레드 로직 수행을 위한 Actor 패턴 구현체다. 스레드에 lock을 걸지 않으면서도 서로 다른 스레드간 간섭 없이 순차실행을 가능하게 하기 위해, 스레드는 현재 자신의 수행상태 일부를 tls에 기록해 두어야 한다. 친절한 ms 형들이 이런 경우를 위해 AsyncLocal 클래스도 미리 만들어 두었다. 생긴것도 서로 비슷해서 문제점 : 의도치 않게 값의 복사 발생이러면 문제는 해결된 것 같지만, 또 다른 문제가 있다. 여기가 이 글의 핵심이다 집중해주기 바란다.
1번 백그라운드 작업 요청 메서드들은 스레드풀을 대상으로 하는 동작이니까 어느 정도 이해가 된다고 하지만, 2번 네트워크 콜백들은 tls를 복사한다는 점이 선뜻 연결이 잘 되지 않는다. managed 메서드의 이름이 낮설어 보일까 싶어 win32에 해당하는 함수명도 같이 적었는데, 그냥 OVERLAPPED 구조체를 이용해 IOCP에 통지를 요청하는 네트워크 api들 전체를 말한다. 0번 스레드가 게임 로직을 열심히 수행하다가 클라이언트로 동기화 패킷을 보낼 상황이 되었다. 그래서 패킷을 만들어 소켓에 SendAsync()를 한 번 걸어놓고, 다시 또 다른 로직을 열심히 수행한다. 근데 0번 스레드가 걸었던 send 요청이 완료되어 새롭게 2번 스레드가 OnSendCompleted 메서드를 실행하려고 깨어났는데, 이 때 0번 스레드가
원치 않는 AsyncLocal 복사는 꺼준다.다행히 이 동작은 ExecutionContext.SuppressFlow / RestoreFlow 라는 메서드가 있어 쉽게 제어가 가능하다. 우선 스레드풀에 백그라운드 작업을 요청할 때는
작업 요청 후에는 네트워크 구현부에도 수정이 필요하다.
이런식으로 SendAsync, RecvAsync 등도 다 막아주어야 일반적인 iocp 콜백 사용 방식과 동일해진다. 다른 코드상에서 아무데도 정리
현재 사용중인 게임서버의 스레드 모델도 승모님의 JobDispatcher와 유사한 Actor 기반 구조를 채택해서 락 없이 구현하고 있다. 지금 서버 구현 기준에서 값이 복사되는 tls 변수가 문제를 일으키는 케이스는 액터를 구현하기 위한 로직 한 군데 뿐이다. 일반적으로 게임 서버를 구현할 때 스레드별로 비대칭적인(asymmetric) tls 변수를 유지해야 하는 경우가 흔치는 않을 것이다. 액터 패턴을 구현한다고 해서 tls 변수가 반드시 필수적인 것도 아니다. 이전 프로젝트에서 tls를 사용하지 않는 액터 구현도 사용해본 적이 있기 때문이다. 하지만 고성능 서버를 목표로 스레드 효율성을 튜닝한다면 반드시 사용을 염두에 두게 되는 도구가 TLS이므로, 본 글에서 언급한 내용을 숙지하고 있으면 성능 튜닝에서 많은 삽질을 세이브 하게 될것이다. |
출처1
https://leafbird.github.io/devnote/2021/01/01/C-%EA%B3%A0%EC%84%B1%EB%8A%A5-%EC%84%9C%EB%B2%84-Thread-Local-Storage/
출처2