MagicOnion 의 StreamingHub 클라이언트는 리시버를 연결한 곳의 스레드에서 부른다
Esc

    MagicOnion 의 StreamingHub 클라이언트는 리시버를 연결한 곳의 스레드에서 부른다

    –C#라이브러리네트워크
    var hub = await StreamingHubClient.ConnectAsync<IChatHub, IChatReceiver>(channel, new ChatReceiver());
    var sum = await hub.AddAsync(1, 2);   // 이 다음 줄은 어느 스레드에서?

    유니티에서 리시버가 메인 스레드가 아닌 곳에서 불려 예외가 나거나, 콘솔 봇에서 리시버 하나가 느려 응답이 전부 늦어지는 일이 있습니다. 같은 연결인데 스레드 규칙이 두 가지이기 때문입니다.

    StreamingHub 클라이언트는 받는 루프 하나를 스레드 풀에서 돌리고, 응답 뒤 코드는 await 한 곳의 컨텍스트로, 리시버 호출은 ConnectAsync 때 붙잡은 SynchronizationContext 로 보냅니다. 붙잡은 것이 null 이면 받는 루프가 리시버를 직접 부릅니다. 기준 판본은 7.11.0 입니다.

    응답은 번호표로 짝짓는다

    응답이 있는 허브 메서드는 Interlocked.Increment 로 메시지 ID 를 뽑고, 응답을 기다리는 객체를 responseFutures 사전에 먼저 건 뒤 메시지를 보내는 줄에 넣습니다. 여러 스레드가 넣은 메시지는 보내는 루프 하나가 차례로 씁니다.

    StreamingHub 클라이언트에는 호출 하나 단위의 제한 시간이 없습니다. 응답이 안 오면 연결이 끊길 때까지 기다립니다.

    응답은 기다린 곳을, 리시버는 연결한 곳을 따른다

    ConnectAsync 는 첫 줄에서 SynchronizationContext.Current 를 붙잡고, 이것이 그 연결의 평생 컨텍스트가 됩니다. 받는 루프는 ConfigureAwait(false) 로 스레드 풀에서 돕니다. 메시지마다 컨텍스트로 돌아가면 유니티에서는 메시지 하나에 한 프레임을 잃기 때문입니다.

    응답을 넣는 객체는 RunContinuationsAsynchronously = true 라서, await 다음 줄이 받는 루프 스레드에서 이어지지 않고 await 한 쪽의 컨텍스트로 예약됩니다. 실측이 규칙대로였습니다. 단일 스레드 펌프 안에서 연결하니 리시버 세 번이 모두 펌프 스레드에서 불렸고, 같은 허브를 컨텍스트 없는 메인에서 await 하자 응답 뒤 코드는 컨텍스트 null 인 다른 스레드에서 이어졌습니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2.)

    컨텍스트가 없으면 리시버가 받는 루프를 막는다

    붙잡은 컨텍스트가 null 이면 받는 루프가 리시버를 직접 부릅니다. 리시버가 끝날 때까지 그 뒤에 도착한 응답, 브로드캐스트, 하트비트를 읽지 못합니다.

    컨텍스트가 있는 유니티에서는 반대 값을 치릅니다. 리시버의 역직렬화와 본문이 메인 스레드 몫이 되고, 하트비트 콜백도 같은 컨텍스트로 Post 되어 메인 스레드가 멈추면 하트비트 응답도 멈춥니다.

    끊기면 기다리던 호출은 같은 문구로 취소된다

    받는 루프가 끝나는 모든 길은 한 finally 로 모여 남은 번호표를 모두 TrySetCanceled("... because the client has disposed.") 합니다. 어느 길로 빠져나왔는지는 WaitForDisconnectAsync() 가 돌려주는 끊긴 이유에만 남습니다.

    서버가 Abort 했을 때 클라이언트의 대기 호출은 5 ms 안에 취소됐고, DisposeAsync 는 요청 스트림을 먼저 닫고 100 ms 기다린 뒤 받는 쪽을 취소하느라 115~117 ms 걸렸습니다. 끊긴 클라이언트 객체는 다시 쓸 수 없고, 다시 붙으려면 ConnectAsync 로 새로 만듭니다.

    주의할 점

    1. 컨텍스트가 없는 곳에서 리시버에 오래 걸리는 일을 두지 않는다

    안 지키면 생기는 문제. 콘솔 도구, 부하 테스트 봇, 서버끼리 잇는 클라이언트에서는 리시버가 곧 받는 루프입니다. 리시버가 1초 멈추자 그 뒤 응답이 1,001~1,015 ms 늦게 왔습니다. 하트비트 응답도 같은 줄에 서니 제한 시간이 짧은 서버는 멀쩡한 연결을 끊습니다.

    이렇게 쓴다. 리시버에서는 받은 값을 Channel 이나 큐에 넣고 바로 돌아옵니다. 무거운 처리와 다른 허브 메서드 await 는 그 큐를 읽는 별도 작업이 맡습니다.

    2. ConnectAsync 는 리시버가 돌아야 할 스레드에서 부른다

    안 지키면 생기는 문제. 유니티에서 await Task.Run(...) 이나 ConfigureAwait(false) 뒤에 ConnectAsync 를 부르면 null 이 붙잡혀, 그 연결의 리시버는 평생 스레드 풀에서 불립니다. 리시버에서 유니티 API 를 부르는 순간 실패하는데, 같은 연결의 await 뒤 응답 처리는 멀쩡해서 원인을 찾기 어렵습니다.

    이렇게 쓴다. 메인 스레드에서, 앞에 ConfigureAwait(false) 가 없는 흐름에서 연결합니다. 연결 코드 첫 줄에 SynchronizationContext.Current 가 기대한 값인지 확인하는 단언을 둡니다.

    3. 허브 메서드가 돌려준 ValueTask 는 한 번만 await 한다

    안 지키면 생기는 문제. 응답을 기다리는 객체는 풀에서 빌린 IValueTaskSource 이고, 첫 await 가 결과를 읽는 순간 풀로 돌아가 다음 호출이 빌려 갑니다. var vt = hub.AddAsync(1, 2); await vt; ... await vt; 를 1,000번 돌리니 두 번째 await 는 1,000번 모두 InvalidOperationException 이었습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)

    이렇게 쓴다. 여러 곳에서 기다려야 하면 처음부터 .AsTask() 로 바꿔 나눠 줍니다. 필드에는 ValueTask 가 아니라 결과나 Task 를 담습니다.

    4. 끊긴 원인은 예외 문구가 아니라 WaitForDisconnectAsync 로 본다

    안 지키면 생기는 문제. 서버가 끊어도, 하트비트로 끊겨도, 내가 DisposeAsync 해도 대기 호출의 예외는 “because the client has disposed” 로 같습니다. 서버 하트비트 제한 시간으로 끊긴 경우는 이유마저 CompletedNormally 입니다. 이것을 보고 “내가 닫았다” 고 판단하면 재접속하지 않습니다.

    이렇게 쓴다. 연결 직후 WaitForDisconnectAsync() 를 기다리는 작업을 띄우고, 내가 DisposeAsync 를 불렀는지 직접 표시해 둡니다. 표시 없이 끊겼다면 이유와 상관없이 새 ConnectAsync 로 다시 붙습니다.

    정리

    StreamingHub 클라이언트는 응답 뒤 코드는 기다린 곳에서, 리시버는 연결한 곳에서 부릅니다.

    • 요청은 번호표를 먼저 걸고 보내며, 호출 단위 제한 시간은 없습니다.
    • 받는 루프는 스레드 풀에서 돌고, 컨텍스트가 없으면 리시버를 직접 불러 수신 전체를 막습니다.
    • 끊기면 대기 호출은 모두 같은 문구로 취소되고, 원인은 WaitForDisconnectAsync() 의 Faulted, CompletedNormally, TimedOut 으로만 갈립니다.