한 플랫폼에서 돌던 소켓 서버를 다른 플랫폼으로 옮기는 일이었다. 같은 C++ 이고 로직도 같으며 I/O 처리 방식만 다른데 그것이 구조 전체를 바꿨다.
Table of contents
Open Table of contents
모델이 달랐다
옛 쪽은 준비되면 알려주는 방식이었다.
"이 소켓에 읽을 게 있다" → 내가 읽는다
새 쪽은 완료되면 알려주는 방식이었다.
"읽어 달라" 요청 → (나중에) "읽었다, 여기 있다"
같은 일인데 순서가 반대다. 앞의 epoll 쪽은 알림을 받고 내가 처리하고 뒤의 IOCP 쪽은 미리 요청해 놓고 결과를 받는다.
epoll 은 읽을 것이 준비됐다고 알려 주고 그때 우리가 읽으며 IOCP 는 WSARecv 로 미리 읽어 달라고 걸어 두면 다 읽었을 때 알려 준다.
버퍼 관리가 달라졌다
이 차이가 버퍼 관리를 바꿨다.
epoll 쪽은 알림을 받고 나서 버퍼를 준비해 recv 하므로 버퍼를 그때 잡으면 된다. IOCP 쪽은 WSARecv 를 부를 때 버퍼를 넘기고 완료될 때까지 그 버퍼가 살아 있어야 한다.
그래서 요청마다 버퍼를 들고 있어야 하고 그것을 관리하는 구조가 필요했다. 연결마다 요청 상태를 담는 구조체를 만들어 어떤 요청이 진행 중이고 어느 버퍼를 쓰는지 담았다.
다만 무엇을 둬야 하는지가 처음 생각과 달랐다. WSABUF 구조체 배열 자체는 스택에 잡아도 되는데 문서에 WSARecv 가 반환하기 전에 그 구조체를 복사해 간다고 적혀 있다.
살아 있어야 하는 것은 그 구조체가 가리키는 실제 데이터 버퍼와 WSAOVERLAPPED 다. 문서는 이것이 작업이 끝날 때까지 유효해야 한다고 적는다. 동시에 여러 작업을 걸면 각각 별도의 구조체를 가리켜야 한다고도 못 박고 있다.
하나를 돌려 쓰면 두 번째 완료가 첫 번째 자리를 덮는다. 옮기면서 이 조건을 놓친 자리가 몇 군데 있었고 그것이 간헐적인 오류로 나타났다.
스레드 모델도 달랐다
epoll 쪽은 한 스레드가 여러 소켓을 돌면서 처리했다. IOCP 쪽은 여러 스레드가 GetQueuedCompletionStatus 로 완료 알림을 나눠 받는다.
그러면 같은 연결의 처리가 다른 스레드에서 일어날 수 있다.
읽기 완료 → 스레드 A가 처리
다음 읽기 완료 → 스레드 B가 처리
연결별 상태를 여러 스레드가 만지므로 CRITICAL_SECTION 같은 동기화가 필요해졌다. 옛 코드는 단일 스레드 전제로 짜여 있어서 그 부분을 다 봐야 했다.
순서에 대한 오해와 조치
처음에는 같은 연결에서 온 두 요청의 완료 순서가 뒤바뀔 수 있다고 봤다. WSARecv 를 두 번 걸면 두 번째가 먼저 끝날 것으로 생각한 것이다.
문서를 보니 그렇지 않았다. IOCP 에서는 WSARecv 를 부른 순서가 곧 버퍼가 채워지는 순서다.
어긋나는 것은 같은 소켓에 서로 다른 스레드가 동시에 WSARecv 를 부를 때다. 문서도 그러면 버퍼 순서를 예측할 수 없다며 하지 말라고 적고 있다.
메시지 순서가 중요한 프로토콜이라 연결당 읽기를 하나만 걸어 두는 방식으로 갔다. 하나가 끝나면 다음을 걸고 거는 자리도 한 흐름으로 제한했다.
처리량이 조금 줄지만 순서가 보장된다. 이 서비스에서는 그것이 중요했다.
검증 — 동등성과 성능 측정 순서
두 버전이 같게 동작하는지 확인해야 했다. 같은 TCP 클라이언트로 양쪽을 붙였고 프로토콜이 같으니 클라이언트는 그대로 쓴다.
시나리오를 몇 개 만들어 양쪽에서 돌리고 결과를 비교했다.
정상 연결·전송·종료
중간에 끊김
큰 메시지
빠른 연속 전송
동시 다수 연결
네 번째와 다섯 번째에서 차이가 나왔는데 앞에 적은 버퍼 수명 문제와 동기화 문제였다. 경계 시나리오를 안 돌렸으면 못 찾았다.
옮기는 목적 중 하나가 성능이었는데 동작이 같아진 뒤에 쟀다. 먼저 재면 빨라졌는데 결과가 다른 상태가 될 수 있고 그것은 의미가 없다.
동작을 맞추고 나서 재니 개선이 있었지만 기대만큼은 아니었고 epoll 쪽과 큰 차이가 아니었다. 병목이 I/O 가 아니라 다른 데 있었고 그것은 별도로 봤다.
정리
- 입출력 방식이 다르면 로직이 같아도 구조가 달라진다
- 준비 알림과 완료 알림은 버퍼 마련 시점이 반대다
- 완료 알림 방식은 넘긴 버퍼가 완료까지 살아 있어야 한다
WSABUF배열은 지역 변수로 둬도 되고 데이터 버퍼와WSAOVERLAPPED는 안 된다- 동시에 여러 작업을 걸면
WSAOVERLAPPED를 각각 따로 준다 - 여러 스레드가 완료를 나눠 받으면 연결별 상태에 동기화가 필요하다
- 완료 순서는 부른 순서를 따르고 어긋나는 것은 여러 스레드에서 부를 때다
- 순서가 중요하면 연결당 하나씩 걸고 거는 자리를 한 흐름으로 제한한다
- 동등성은 같은 클라이언트로 양쪽을 붙여 확인한다
- 끊김과 큰 메시지와 연속 전송과 동시 연결을 돌린다
- 성능은 동작이 같아진 뒤에 잰다