연결 하나가 느려지자 나머지가 전부 멈췄다 · 운영자의 연결 진단기: 예산·원인·회수 · 실습
연결 진단기: 동시에 두드리고 안전하게 그만두기
목표
여러 TCP 연결 시도를 제한된 예산으로 진행하고, 거절·마감·취소를 구분하며 모든 자원을 회수하는 진단기를 만듭니다.
90분 실습입니다. 기본 세션은 60분이므로 만료 전에 +시간으로 연장하세요(최대180분). 세션이 끝나면 파일이 사라집니다. 중요한 작업물은 종료 전에 별도로 보관하세요.
왜 중요한가
연결 성공만 보는 진단기는 실패 원인을 숨기고, 취소가 안 되는 도구는 사용자가 강제 종료하게 만듭니다. 이번 실습은 앞의 공정성·SO_ERROR·제어 채널을 한 반복문에서 연결합니다. 실제 통신은 실습 환경의 loopback만 사용하며 외부 서버를 검사하지 않습니다. 데이터 전달·HTTP·TLS·재시도·DNS·서버의 graceful shutdown은 이 실습의 범위가 아닙니다. DefaultSelector 기반 완료 처리이며 ET 읽기 서버가 아닙니다.
검사는 소스 파일을 임포트하여 동작을 시험합니다. 파일 맨 위에서 서버를 띄우거나 입력을 기다리지 마세요. 추가 패키지와 권한은 필요 없습니다. /opt/fixtures/reactor의 세 probe.py는 별도 개념 관찰용이며 수정하거나 제출할 재료 파일이 아닙니다.
단계
1. 연결 시도의 입력과 초기 상태를 정의한다 — /root/reactor/client.py에서 Dial(sock, host, port, deadline=None)을 구현하세요. 입력 sock은 호출자가 연 서로 다른 Linux AF_INET/SOCK_STREAM 소켓입니다. host는 숫자 IPv4 문자열만, port는 bool을 제외한 1–65535 int만 받으며 위반은 ValueError입니다. deadline=None이면 time.monotonic()+30, 지정 값은 bool을 제외한 유한·음이 아닌 int/float여야 합니다. sock을 비차단으로 바꾸고 sock, endpoint=(정규화한 host, port), float인 deadline, state='new', error=None을 보관하세요. 생성 실패 시 소켓 회수는 호출자 책임입니다.
2. 즉시 성공과 연결 중 상태를 구분한다 — /root/reactor/client.py에서 Dial.start()는 new에서만 sock.connect_ex(endpoint)를 한 번 호출합니다. 0/EISCONN은 connected·error=0, EINPROGRESS/EALREADY는 pending·error=None, 다른 코드는 failed·error=코드입니다. OSError는 errno를 기록하고 errno가 없으면 EIO입니다. new가 아니면 호출 없이 기존 state를 반환합니다. 모든 경로에서 state 문자열을 반환하며, 이 Linux IPv4 계약에서는 EAGAIN을 pending에 포함하지 않습니다.
3. 최초 SO_ERROR를 보존한다 — /root/reactor/client.py에서 Dial.writable()은 pending일 때만 getsockopt(SOL_SOCKET, SO_ERROR)를 한 번 읽고 0/EISCONN은 connected·0, 다른 코드는 failed·해당 코드로 기록합니다. OSError는 errno 또는 EIO로 실패 처리합니다. pending이 아니면 조회하지 않습니다. 현재 state를 반환합니다. 호출자는 WRITE 준비를 관찰한 뒤에만 pending의 이 메서드를 호출해야 합니다. 실제 TCP listen 포트와 bind만 한 거절 포트도 검사합니다.
4. 대기열 마감과 취소 사유를 보존한다 — /root/reactor/client.py에서 Dial.expire(now)는 new/pending이며 now >= deadline일 때만 timed_out·ETIMEDOUT으로 바꿉니다. now는 호출자가 제공하는 유한 단조 시각입니다. Dial.cancel()은 new/pending만 cancelled·ECANCELED로 바꿉니다. 두 메서드는 이미 종료한 상태와 오류를 덮지 않으며 소켓을 직접 닫지 않습니다. deadline도 연장하지 않습니다.
5. 중복 없는 준비 큐를 만든다 — /root/reactor/client.py에서 ReadyQueue()에 push(item), pop(), discard(item), __len__을 구현하세요. item은 None이 아닌 해시 가능한 연결 식별자입니다. push는 이미 있으면 무시, pop은 FIFO로 제거·반환하고 비었으면 None, discard는 해당 항목을 제거하고 없어도 안전해야 합니다. 꺼낸 항목은 다시 추가할 수 있으며 같은 FD의 다른 세대 식별자는 구분합니다. run에서는 Dial 객체 자체를 키로 씁니다.
6. 정지 상태를 기록하고 커널 대기를 깨운다 — /root/reactor/client.py에서 Control()은 비차단 socketpair의 reader/writer와 처음 False인 threading.Event stop_event를 만듭니다. request_stop()은 Event.set 후 writer.send(b'Q')를 한 번 호출하고 BlockingIOError만 무시합니다. drain()은 reader.recv(4096)를 한 번 호출하여 bytes를 반환하되 BlockingIOError는 b'', EOF는 ConnectionError입니다. close()는 reader를 닫다 예외가 나도 writer까지 닫습니다. run 종료 뒤 request_stop 호출은 계약 밖입니다.
7. 상한과 예산을 지키며 모든 소켓을 회수한다 — /root/reactor/client.py에서 run(dials, control, limit=8, budget=4, ready=None)을 구현하세요. dials는 서로 다른 열린 소켓을 가진 서로 다른 새 Dial의 list(최대128개), limit은 bool 제외 1–128 int, budget은 bool 제외 1–64 int입니다. 목록 타입·개수·Dial 중복·new 상태와 두 예산을 검증해 위반 시 ValueError로 거절하며 이때 소유권은 호출자에게 남습니다. DefaultSelector 생성에 성공하면 함수가 모든 데이터 소켓과 Control을 소유합니다. 제어 reader를 READ/data=None으로 등록하고 ready가 있으면 한 번 호출합니다. 매 턴 정지를 먼저 확인하고 new/pending에 마감을 적용합니다. 정지면 미완료 전부 cancel하고 종료합니다. 한 턴에 최대 budget개를 시작하고 pending 최대 limit개를 WRITE/data=Dial로 등록합니다. 즉시 종료 항목은 회수합니다. 대기는 가장 가까운 마감까지 시간과 1초 중 작은 값(최소0)이며, 준비 큐가 남거나 대기 목록에 시작할 여유가 있으면 0입니다. select 결과의 제어 이벤트는 drain, WRITE는 중복 없는 준비 큐에 넣습니다. 정지를 다시 확인한 뒤 큐에서 최대 budget개만 꺼내고, I/O 직전 마감 확인 후 pending의 writable을 호출합니다. 종료된 항목은 큐에서 삭제·unregister 후 close합니다. 예외가 나도 다른 데이터 소켓과 Control, selector까지 회수하고 오류를 전파해야 합니다. 한 회수의 실패가 나머지 회수를 막지 않게 하세요. 빈 목록도 Control을 회수하고 []를 반환합니다. 정상 종료는 입력 순서의 {'state': 상태, 'error': 정수} list입니다. 진단 연결은 성공해도 닫으며 재사용하지 않습니다.
8. 성공과 실패 원인을 손실 없이 집계한다 — /root/reactor/client.py에서 summarize(results)는 dict들의 list만 받고 위반은 ValueError입니다. 각 행은 state가 connected/failed/timed_out/cancelled, error는 bool 제외 int여야 합니다. connected는 0, 다른 상태는 양수이며 timed_out은 ETIMEDOUT, cancelled는 ECANCELED여야 합니다. pending 등 미완료도 ValueError로 거절합니다. 반환은 total과 네 상태별 건수, errors 사전입니다. errors는 connected를 제외한 errno의 문자열을 키로 합산하고 키를 정렬합니다. 빈 입력은 total·네 상태 모두0과 errors={}입니다. 원본 목록과 행은 수정하지 않습니다. 이 단계는 앞의 실제 TCP·마감·취소·예산·예외 회수도 재검사합니다.
참고
mkdir -p /root/reactor로 작업 디렉터리를 먼저 만드세요. 형식 예시의 NotImplementedError는 완성 답이 아닙니다.python3 /opt/fixtures/reactor/check.py 7 /root/reactor/client.py처럼 누적 검사를 직접 실행할 수 있습니다. 단계 숫자는 1–8입니다.- 실제 TCP 성공/거절 외에 합성 연결 상태와 실제 socketpair 쓰기 대기로 마감·취소를 검증합니다. 원격 네트워크 장애를 재현했다고 해석하지 않습니다.
- limit은 pending 상한이며 미리 열린 전체 소켓 수가 아닙니다. 초기화 실패나 잘못된 인자를 받은 경우 호출자가 자원을 정리해야 합니다.
- 정답 보기는 누적 소스를 덮어쓰므로 자기 코드를 먼저 보관하세요. 등록 해제·close 순서와 모든 예외 경로를 직접 설명할 수 있어야 완성입니다.
단계 8개
- 연결 시도의 입력과 초기 상태를 정의한다
- 즉시 성공과 연결 중 상태를 구분한다
- 최초 SO_ERROR를 보존한다
- 대기열 마감과 취소 사유를 보존한다
- 중복 없는 준비 큐를 만든다
- 정지 상태를 기록하고 커널 대기를 깨운다
- 상한과 예산을 지키며 모든 소켓을 회수한다
- 성공과 실패 원인을 손실 없이 집계한다