LabHub
배우기 러닝패스 코스

연결 하나가 느려지자 나머지가 전부 멈췄다 · 먼저 입을 다물었을 뿐인데 답장까지 사라졌다 · 실습

대문자 인쇄소에서 사라진 답장을 복구한다

LabHub 에서 이어서 보기

목표

송신 방향만 종료한 클라이언트에게 끝까지 답장하고, 메모리와 총수명을 제한하는 다중화 서버 구성요소를 만듭니다.

왜 중요한가

EOF에서 양방향을 닫으면 아직 보내지 않은 응답이 사라집니다. 반대로 무한히 기다리면 느린 손님이 연결 자원을 붙잡습니다. 입력 종료, 출력 잔여, 최종 사유를 나누어 두 문제를 함께 해결합니다. 실습은 연결당 EOF로 끝나는 한 요청과 ASCII 바이트 대문자 변환이며 HTTP나 TLS 구현이 아닙니다. 기존 줄 단위 서버는 수정하지 않고 별도 파일에 작성합니다.

단계

1. 종료 사유와 소켓 소유권을 나눈다 — Peer(sock, max_bytes=4096, expires_at=None)를 만드세요. max_bytes는 bool을 제외한 1~65536 int입니다. expires_at이 None이면 time.monotonic()+30.0으로 정하고, 직접 지정하면 bool을 제외한 음이 아닌 유한 int/float여야 합니다. 위반은 ValueError입니다. sock을 논블로킹으로 설정하고 sock·max_bytes·float(expires_at)를 보관하세요. inbox/outbox는 빈 bytes, read_eof/prepared/closed는 False, reason은 빈 문자열입니다. finish(reason)는 최초 호출에서만 closed=True, reason 보관, 두 버퍼 비우기를 수행하고 소켓은 닫지 않습니다.
2. EOF와 한도 초과를 구분한다 — Peer.read_once()를 구현하세요. closed 또는 read_eof면 recv 없이 0입니다. 나머지는 recv(min(4096, max_bytes+1-len(inbox)))를 한 번 호출합니다. BlockingIOError는 상태 유지와 0, 다른 OSError는 finish('io_error')와 0입니다. b''이면 read_eof=True와 0일 뿐 닫지 않습니다. 받은 바이트는 inbox에 붙이고 한도 초과면 finish('request_limit')합니다. 데이터가 있었으면 받은 길이를 반환합니다. expires_at은 바꾸지 않습니다.
3. 입을 다문 손님에게 답장을 만든다 — Peer.prepare_reply()를 만드세요. closed, EOF 이전, prepared=True이면 아무것도 하지 않습니다. 그 외에는 prepared=True로 표시하고 inbox.upper()를 outbox에 옮긴 다음 inbox를 비웁니다. outbox가 비어 있을 때만 finish('complete')합니다. 여러 번 호출해도 남은 답장을 다시 생성하지 않습니다. 문자열 디코딩 없이 bytes.upper()를 씁니다.
4. 실제로 나간 만큼만 지운다 — Peer.write_once()는 closed 또는 빈 outbox면 send 없이 0입니다. 그 외에는 outbox[:4096]을 한 번 send하고 반환된 길이만 앞에서 지운 뒤 그 길이를 반환합니다. BlockingIOError는 버퍼 유지와 0, 다른 OSError 또는 send 반환 0은 finish('io_error')와 0입니다. 전송 후 outbox가 비고 read_eof이면 finish('complete')합니다.
5. EOF 이후 읽기 관심을 끈다 — Peer.events()는 closed이면 0입니다. 나머지는 read_eof=False일 때 EVENT_READ, outbox가 있을 때 EVENT_WRITE를 OR한 int를 반환합니다. 소켓이나 버퍼를 바꾸지 않습니다. EOF 뒤 응답 준비는 반복문이 즉시 수행하므로 실제 등록 마스크 0은 종료한 연결에만 적용합니다.
6. 한 바이트로 수명이 무한 연장되지 않게 한다 — Peer.expire(now)는 closed가 아니고 now >= expires_at일 때만 finish('deadline')합니다. now는 호출자가 제공하는 유한 단조 시각입니다. EOF 뒤 답장이 남아 있어도 동일하게 적용하고, 이미 종료한 사유를 바꾸지 않습니다.
7. 등록을 해제한 다음 FD를 회수한다 — 모듈 함수 retire(selector, peer)는 selector.unregister(peer.sock) 후 peer.sock.close()를 수행합니다. 아직 등록되지 않아 KeyError가 나도 close하고, 그 외 오류는 close 후 다시 전파합니다. peer.reason을 바꾸지 않습니다. 유효한 열린 소켓이 입력이며 실제 close는 소유자인 반복문이 한 번만 호출합니다.
8. 침묵한 손님 옆에서 실제 TCP 답장을 완성한다 — serve(peers)를 만드세요. 입력은 서로 다른 열린 연결 소켓을 가진 새 Peer의 list이며 함수가 소켓 소유권을 받습니다. DefaultSelector로 모든 연결을 함께 처리하고 select 대기는 0.05초와 가장 가까운 마감까지 시간 중 작은 값(최소 0)입니다. 매 반복과 I/O 직전에 expire(time.monotonic())를 확인합니다. READ에서 read_once 다음 prepare_reply, WRITE에서 write_once를 호출하며 관심 마스크를 갱신합니다. 종료 연결은 retire로 제거하고, 예외가 나도 나머지 소켓과 selector를 회수합니다. 모두 종료하면 입력 순서의 reason list를 반환하며 빈 입력은 []입니다. 듣기·accept·스레드 생성은 이 함수에서 하지 않습니다. 채점은 4개 실제 TCP 연결과 앞 단계 계약을 재실행합니다.

참고

단계 8개

  1. 종료 사유와 소켓 소유권을 나눈다
  2. EOF와 한도 초과를 구분한다
  3. 입을 다문 손님에게 답장을 만든다
  4. 실제로 나간 만큼만 지운다
  5. EOF 이후 읽기 관심을 끈다
  6. 한 바이트로 수명이 무한 연장되지 않게 한다
  7. 등록을 해제한 다음 FD를 회수한다
  8. 침묵한 손님 옆에서 실제 TCP 답장을 완성한다