LabHub
배우기 러닝패스 코스

연결 하나가 느려지자 나머지가 전부 멈췄다 · 취소 버튼이 안 먹는다: 제어 채널과 소유권 · 이론

취소 버튼이 안 먹는다: 제어 채널과 소유권 이해하기

LabHub 에서 이어서 보기

한 줄 요약

정지 의사를 기록하는 것과 커널에서 잠든 루프를 깨우는 것은 별개입니다. 상태를 먼저 기록하고 제어 소켓으로 알린 뒤, 소켓 소유자인 루프가 정리합니다.

왜 이게 필요했나

진단 화면에 취소 버튼을 달았습니다. 다른 스레드는 stop=True를 썼지만 반복문은 select 안에서 기다립니다. 사용자는 버튼이 안 먹힌다고 느끼고 여러 번 누릅니다. 메모리의 값은 이미 바뀌었어도 루프가 다음 줄을 실행하지 못하면 그 값을 볼 기회가 없습니다. select의 대기를 매우 짧게 바꾸면 반응은 빨라지지만 유휴 상태에도 반복해서 깨어나야 합니다.

이때 필요한 것은 '상태와 알림' 두 부분입니다. 상태는 취소가 요청됐다는 사실을 보존하고, 알림은 그 사실을 읽을 차례를 만듭니다. 알림을 받았다는 것만으로 취소가 처리됐거나 모든 자원이 회수됐다고 판단하지 않습니다. 요청·관찰·정리 완료를 나눠 생각하면 UI와 백엔드의 경계도 명확해집니다.

어떻게 동작하나

[threading.Event](https://docs.python.org/3/library/threading.html#event-objects)는 플래그를 보관하고 Event.wait로 기다리는 스레드에 알립니다. 소켓 준비를 기다리는 selector에 자동으로 이벤트를 넣는 장치는 아닙니다. 실습은 socketpair의 읽는 끝을 selector에 등록하고, 쓰는 끝을 요청자에게 남깁니다. 데이터 연결의 완료와 제어 알림을 하나의 대기에서 함께 볼 수 있습니다.

python3 /opt/fixtures/reactor/wakeup_probe.py

실험은 select(2)에 들어가는 스레드를 준비하고 Event를 설정합니다. 짧게 관찰하는 동안 스레드는 끝나지 않습니다. 이어서 Q 한 바이트를 제어 소켓에 보내면 읽기 준비가 생기고, 루프가 깨어나 이미 설정된 Event를 확인합니다. 이 관찰 시간은 운영 환경의 보장 지연이 아니라 상태 변경만으로 소켓 대기가 깨어나지 않는다는 반례를 만드는 조건입니다.

반드시 상태가 먼저다

알림을 먼저 보내고 나중에 Event를 설정하면 루프가 그 사이 깨어날 수 있습니다. 아직 정지가 아니라고 보고 다시 잠든 뒤에는 새 알림이 없을 수 있습니다. 따라서 request_stop은 Event.set 다음 writer.send 순서입니다. 제어 소켓은 양끝 모두 비차단으로 만듭니다. UI 쪽 취소 요청이 송신 버퍼가 빌 때까지 멈추면 제어 채널이 새로운 병목이 됩니다.

버퍼가 가득 차서 BlockingIOError가 나면 이미 읽을 데이터가 있다는 뜻입니다. 정지 상태는 Event에 남아 있으므로 같은 알림을 끝없이 재시도하지 않습니다. 요청 여러 개가 하나의 깨우기로 합쳐져도 되는 설계입니다. 바이트 하나를 사용자 요청 하나와 대응시키는 명령 큐와는 다릅니다. 주문·취소 ID처럼 모든 메시지를 처리해야 하는 채널이라면 별도의 큐와 프레이밍이 필요합니다.

drain은 최대 4096바이트 한 번만 읽습니다. 알림을 다 비우겠다고 무한히 읽으면 알림을 쏟는 스레드가 데이터 처리를 굶길 수 있습니다. 남은 알림은 다음 턴에 처리합니다. 빈 상태의 EAGAIN과 채널 종료 EOF도 다릅니다. EAGAIN은 지금 읽을 것이 없고, EOF는 제어 연결의 상대가 닫혔다는 뜻입니다. 실습은 예기치 않은 EOF를 ConnectionError로 처리하고 전체 자원을 회수합니다.

알림 횟수와 처리 완료를 구분한다

사용자가 취소를 세 번 눌렀어도 종료 처리는 한 번이면 충분합니다. 응답 화면에 취소 완료를 표시할 때는 보낸 바이트 개수가 아니라 run이 끝났는지를 확인해야 합니다. 반대로 완료 통지를 기다리는 스레드가 제어 writer를 먼저 닫으면 EOF가 발생해 정상 취소와 다른 예외 경로로 들어갈 수 있습니다. 종료 버튼, 요청 스레드, 반복문 사이에 어떤 순서로 소유권을 넘기는지 짧은 타임라인을 그려 보면 이런 경합을 찾기 쉽습니다.

누가 close하는가

요청 스레드가 데이터 소켓들을 임의로 닫지 않습니다. 루프는 그 소켓의 등록 상태와 준비 큐를 함께 관리하고 있어야 합니다. 취소 요청자는 Event와 제어 writer만 사용하고, 루프는 미완료 Dial을 cancelled로 바꾼 다음 등록 해제와 close를 수행합니다. [selectors 문서](https://docs.python.org/3/library/selectors.html)의 등록 해제 후 닫기 순서를 지킵니다.

이번 API는 run을 호출하면 유효한 입력의 소켓과 Control을 맡기며 반환 또는 예외 후 다시 사용하지 않습니다. 잘못된 인자를 실행 전에 거절하면 소유권은 여전히 호출자에게 있습니다. run 종료 뒤 request_stop을 또 부르는 것은 허용하지 않습니다. 실제 화면과 연결할 때는 완료 상태를 공유하고 요청자가 끝나기 전에 채널을 해제하지 않는 수명주기 협약이 필요합니다.

현장에서 만나는 모습

운영 도구가 종료 요청을 받았는데도 멈추지 않으면 사용자들은 프로세스를 강제 종료하게 됩니다. 데이터 보존이 필요한 서버에서는 새 요청 거절, 진행 중 요청 배출, 제한 시간 후 강제 종료가 더 필요합니다. 이번 것은 연결 진단기의 즉시 취소입니다. 서버의 graceful shutdown 전체를 구현했다고 표현하면 안 됩니다.

다음 확인에서 할 것

다음 퀴즈에서는 상태와 알림의 순서를 판단합니다. 이후 종합 실습에서 Control을 만든 뒤 실제 커널 대기에 들어간 루프를 다른 스레드에서 깨웁니다. 정지 플래그만 바꾸는 오답과 제어 소켓을 블로킹으로 두는 오답을 구분하세요. 최종 판정은 버튼을 눌렀다는 로그가 아니라 미완료 결과의 취소 분류와 모든 FD 회수입니다.