취소는 부탁이지 명령이 아니다
서버를 내립니다. 지금 돌고 있는 것들이 이만큼입니다.
- 세션 수백 개의 수신 루프
- 방 틱 루프 (10Hz)
- 하트비트 검사 루프 (15초마다)
- 송신 루프 (세션마다 하나)
이것들을 어떻게 세울까요. 강제로 죽이면 될 것 같은데, 그 방법이 없습니다.
Thread.Abort 는 .NET Core 부터 아예 사라졌습니다. 부르면 예외가 납니다.
없앤 이유가 분명합니다. 아무 데서나 끊으면 뒷정리를 못 합니다.
자물쇠를 잡은 채 죽으면 그 자물쇠는 영원히 안 풀리고, 파일을 반쯤 쓰다 죽으면 그 파일은 깨집니다.
그래서 취소는 부탁입니다. 받는 쪽이 알아서 정리하고 나가 줘야 합니다.
CancellationToken 은 둘로 되어 있다
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
cts.Cancel(); // 부탁하는 쪽
취소를 시키는 쪽은 CancellationTokenSource, 받는 쪽은 CancellationToken 입니다.
일부러 나눠 놨습니다. 토큰만 받은 코드는 취소를 당할 수만 있고 시킬 수는 없습니다. 남에게 토큰을 넘겨도 그쪽이 내 취소를 발동시킬 수 없다는 뜻입니다.
CancellationTokenSource 는 IDisposable 입니다. 타이머를 걸어 두는 경우가 있어서 정리가 필요합니다.
받는 쪽이 하는 일
토큰을 받았으면 둘 중 하나는 해야 합니다. 안 하면 취소해도 안 멈춥니다.
하나. 주기적으로 확인합니다.
while (Connected && !ct.IsCancellationRequested)
{
...
}
둘. 아래로 그대로 넘깁니다.
byte[] lengthBytes = await ReceiveExactAsync(4, ct);
await _dispatcher.Dispatch(this, packet, ct);
넘기는 쪽이 더 중요합니다. 기다리는 자리에 토큰이 안 들어가면 취소가 안 먹습니다.
ReceiveExactAsync 안에서 소켓을 기다리는 중인데 토큰을 안 줬으면, 클라이언트가 뭔가 보낼 때까지 안 깨어납니다.
그래서 CancellationToken 이 매개변수 목록에 계속 따라다닙니다. 제 서버에서는 172개 파일에 있습니다.
귀찮아 보이는데, 안 넘기면 그 지점부터 취소가 끊깁니다.
기다리는 것에는 전부 넘긴다
Task.Delay 가 좋은 예입니다.
await Task.Delay(CheckInterval, stoppingToken);
토큰을 안 주면 서버를 내려도 15초를 다 기다린 뒤에야 종료됩니다. 주면 그 자리에서 깨어납니다.
PeriodicTimer 도 같습니다.
while (await timer.WaitForNextTickAsync(stoppingToken))
취소되면 false 를 돌려주거나 예외를 던져서 루프가 끝납니다.
취소는 예외로 온다
여기가 처음에 헷갈리는 부분입니다.
토큰을 받은 await 가 취소되면 OperationCanceledException 이 던져집니다.
그런데 이건 오류가 아니라 정상 종료입니다. 우리가 부탁한 대로 된 것이니까요.
그래서 이렇게 구분해서 잡습니다.
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogInformation("Session loop canceled for session {SessionId}", SessionId);
}
catch (SocketException e) when (IsExpectedDisconnect(e.SocketErrorCode))
{
_logger.LogInformation("Session {SessionId} disconnected by peer", SessionId);
}
catch (Exception e)
{
_logger.LogError(e, "Session loop failed for session {SessionId}", SessionId);
}
when 절이 핵심입니다. 내가 취소한 것일 때만 조용히 넘어갑니다.
when 없이 OperationCanceledException 을 전부 삼키면, 남이 준 토큰이 취소돼서 온 것까지 정상으로 처리합니다.
그러면 진짜 문제를 못 봅니다.
로그 수준도 갈랐습니다. 정상 종료는 Information, 진짜 실패는 Error 입니다.
정상 종료가 에러 로그를 남기면 그 로그는 곧 아무도 안 보게 됩니다.
나갈 때 정리한다
취소가 왔다고 그냥 나가면 안 됩니다. 잡고 있던 것을 놓아야 합니다.
finally
{
RoomManager.LeaveRoom(this, graceful: true);
Disconnect();
// 큐를 닫아 SendLoop 을 깨우고 회수한다 — 안 하면 세션마다 Task 가 영원히 남는다(누수).
_sendQueue.Writer.TryComplete();
try { await sendLoop; } catch { /* 종료 경로 */ }
}
앞에서 송신 루프가 WaitToReadAsync 로 큐를 기다린다고 했습니다.
수신 루프만 끝내고 나가면 그 송신 루프는 영원히 기다립니다. 세션마다 Task 가 하나씩 쌓입니다.
그래서 큐를 닫아 깨우고, await 로 정말 끝난 것을 확인하고 나갑니다.
try/catch 로 감싼 것은 종료 경로에서 나는 실패는 이미 처리했기 때문입니다.
이게 강제 종료로는 못 하는 일입니다. 죽여 버리면 이 finally 가 안 돕니다.
시키는 쪽
리스너를 보면 취소를 시키는 쪽 전체가 보입니다.
public void Start(int backlog = 512)
{
if (Interlocked.Exchange(ref _running, 1) == 1)
return;
_cts = new CancellationTokenSource();
...
_acceptLoopTask = AcceptLoopAsync(_cts.Token);
}
public void Stop()
{
if (Interlocked.Exchange(ref _running, 0) == 0)
return;
_cts?.Cancel();
_listenSocket?.Close();
_listenSocket?.Dispose();
_cts?.Dispose();
}
Interlocked.Exchange 로 두 번 켜고 끄는 것을 막습니다.
그리고 Cancel() 다음에 Dispose() 순서입니다. 놓기 전에 부탁부터 합니다.
시간으로도 취소한다
취소가 꼭 사람이 시키는 것만은 아닙니다. 시간이 시키기도 합니다.
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await 뭔가Async(cts.Token); // 5초 넘으면 알아서 취소
제 서버의 하트비트가 같은 발상입니다. 다만 토큰 대신 직접 시간을 잽니다.
var timeout = session.Room != null ? RoomPlayerTimeout : SessionTimeout;
if (now - session.LastRecvAt <= timeout)
continue;
...
session.Disconnect();
방에 있는 사람은 1분, 로비에 있는 사람은 30초입니다. 상태에 따라 참을성이 다릅니다. 게임 중인 사람을 30초 만에 끊으면 잠깐 끊긴 사람이 억울하고, 로비에서 놀고 있는 유령 연결을 1분씩 붙잡고 있을 이유는 없습니다.
취소를 이야기할 때 이 부분이 빠지기 쉬운데, 얼마나 기다릴 것인가를 정하는 것이 실무에서는 절반입니다.
정리
- 스레드를 강제로 죽이는 방법은 없습니다. 뒷정리를 못 하기 때문에 없앴습니다
CancellationTokenSource가 시키고CancellationToken이 받습니다. 나눠 둔 이유가 있습니다- 받는 쪽은 확인하거나 아래로 넘기거나 해야 합니다. 안 하면 취소가 안 먹습니다
- 기다리는 자리에 토큰을 안 주면 그 지점부터 취소가 끊깁니다.
Task.Delay도 마찬가지입니다 - 취소는 예외로 옵니다.
when (ct.IsCancellationRequested)로 정상 종료와 진짜 오류를 구분합니다 - 나갈 때 잡고 있던 것을 놓습니다. 큐를 닫아 소비자를 깨우지 않으면
Task가 남습니다 - 시간으로도 취소합니다. 얼마나 기다릴지는 상태마다 다릅니다