연결 하나가 느려지자 나머지가 전부 멈췄다 · 이 방식이 무너지는 자리 · 실습
한 반복문으로 연결 수백 개를 받아 낸다
목표
순차 서버의 줄서기를 직접 재고, 같은 부하를 한 반복문으로 받아 내는 다중화 서버를 만들어 두 측정값을 견줍니다.
왜 중요한가
연결 하나가 말을 늦게 시작하면 순차 서버는 그동안 아무 일도 하지 못합니다. CPU 는 한가하고 로그도 조용해서, 지표만 보면 막혔다는 사실 자체가 보이지 않습니다. 기다리는 자리를 연결마다 두지 않고 한 곳으로 모으면 이 문제가 사라지지만, 대신 부분 읽기와 부분 쓰기, 관심 이벤트, 유휴 정리를 프로그램이 직접 관리해야 합니다. 이 실습은 그 관리 비용을 하나씩 만들어 보고, 바꾸기 전과 후를 같은 자로 재서 무엇이 실제로 좋아졌는지 숫자로 남깁니다. 표준 라이브러리만 쓰고 인터넷 접속이나 설치는 필요 없습니다.
단계
1. 멈추지 않는 듣는 소켓 — /root/mux/server.py 에 make_listener(host, port, backlog=64) 를 만드세요. AF_INET·SOCK_STREAM 소켓에 SO_REUSEADDR 를 켜고 host 와 port 로 bind 한 뒤 backlog 로 listen 하고, setblocking(False) 로 바꾼 소켓을 돌려줍니다. port 0 은 운영체제가 빈 포트를 고르도록 허용합니다. host 가 비어 있지 않은 문자열이 아니거나, port 가 0 부터 65535 까지의 int 가 아니거나, backlog 가 1 이상의 int 가 아니면 ValueError 입니다. bool 은 int 로 받지 않습니다.
2. 줄서기를 숫자로 남긴다 — python3 /opt/fixtures/mux/probe.py --target sequential 을 돌리세요. 출력 끝의 blocked 와 fast 두 값을 /root/mux/report.txt 에 seq_blocked=<값> 과 seq_fast=<값> 두 줄로 적습니다. 채점기가 같은 측정을 그 자리에서 다시 돌려 두 값을 대조합니다.
3. 한 번 깨어났을 때 대기열을 비운다 — server.py 에 accept_all(listener) 를 추가하세요. BlockingIOError 가 날 때까지 accept 를 반복해 받아들인 소켓의 list 를 돌려줍니다. 기다리는 연결이 없으면 빈 list 이고, 받아들인 소켓도 setblocking(False) 로 바꿉니다. ConnectionAbortedError 나 ConnectionResetError 가 난 연결은 건너뛰고 남은 대기열은 계속 받습니다.
4. 받은 만큼만 읽고 나머지는 보관한다 — server.py 에 Conn 클래스를 추가하세요. Conn(sock, now=0.0) 은 inbox 와 outbox 를 빈 bytes 로, closed 를 False 로, last_active 를 float(now) 로 둡니다. on_readable() 은 sock.recv 를 한 번만 부르고 받은 바이트를 inbox 에 이어 붙인 뒤, 개행으로 끝난 줄만 개행을 떼어 list 로 돌려줍니다. 미완성 조각은 inbox 에 남깁니다. recv 가 b"" 를 주면 closed 를 True 로 두고 빈 list, BlockingIOError 는 아무것도 바꾸지 않고 빈 list, ConnectionResetError 는 closed 를 True 로 두고 빈 list 입니다. 길이 0 인 줄도 줄 하나입니다.
5. 보낸 만큼만 지운다 — Conn 에 queue(data) 와 wants_write 속성과 on_writable() 을 추가하세요. queue 는 bytes 만 받아 outbox 뒤에 이어 붙이고, bytes 가 아니면 ValueError 입니다. wants_write 는 outbox 가 비어 있지 않은지 여부입니다. on_writable 은 outbox 가 비었으면 send 없이 0 을, 아니면 sock.send 를 한 번 불러 돌아온 수만큼만 outbox 앞에서 잘라내고 그 수를 돌려줍니다. BlockingIOError 는 0 이고 outbox 를 그대로 둡니다. BrokenPipeError 와 ConnectionResetError 는 closed 를 True 로 두고 0 입니다.
6. 쓰기는 보낼 것이 있을 때만 지켜본다 — server.py 에 interest(conn) 을 추가하세요. conn 의 outbox 가 비어 있으면 selectors.EVENT_READ 하나만, 남아 있으면 selectors.EVENT_READ 와 selectors.EVENT_WRITE 를 OR 로 합친 값을 돌려줍니다.
7. 조용한 연결을 고른다 — Conn 에 touch(now) 를, server.py 에 idle_keys(conns, now, idle_timeout) 을 추가하세요. touch 는 last_active 를 float(now) 로 바꿉니다. idle_keys 는 열쇠에서 Conn 으로 가는 딕셔너리를 받아 now - last_active 가 idle_timeout 이상인 열쇠만 정렬해 list 로 돌려주고, 받은 딕셔너리는 바꾸지 않습니다. idle_timeout 이 양의 유한한 int 나 float 가 아니면 ValueError 이며 bool 은 받지 않습니다.
8. 한 반복문으로 전부 받고 다시 재서 견준다 — server.py 에 serve(host, port, idle_timeout, ready=None, clock=None) 을 추가하세요. make_listener 로 듣기 시작한 뒤 ready 가 있으면 실제로 bind 된 포트 번호로 한 번 부르고, selectors 로 듣는 소켓과 모든 연결을 함께 지켜보며 반복합니다. REQ 뒤에 값이 붙은 줄을 받으면 OK 와 같은 값을 붙인 줄을 그 연결의 outbox 에 넣고, interest 로 관심 이벤트를 갱신하며, closed 인 연결과 idle_keys 가 고른 연결은 관심 목록에서 빼고 닫습니다. 이벤트가 없어도 유휴 검사가 돌도록 다중화 호출의 대기 시간에 상한을 두세요. 그다음 python3 /opt/fixtures/mux/probe.py --target /root/mux/server.py 와 python3 /opt/fixtures/mux/fdcount.py /root/mux/server.py 를 돌려 /root/mux/report.txt 에 mux_blocked=<값>, mux_fast=<값>, mux_fds=<값> 세 줄을 더하세요.
참고
- 모든 함수는 /root/mux/server.py 하나에 두고, 측정값은 /root/mux/report.txt 에 적습니다. mkdir -p /root/mux 로 작업 폴더를 먼저 만드세요.
- 측정 도구는 /opt/fixtures/mux 에 있습니다. probe.py 는 줄서기를, fdcount.py 는 연결 200개일 때의 파일 서술자 수를 재고, seqserver.py 는 견줄 기준이 되는 순차 서버입니다. 셋 다 읽어 봐도 됩니다.
- 채점기는 적어 낸 숫자를 그 자리에서 다시 잽니다. 그럴듯한 값을 적는 것으로는 통과하지 않습니다.
- 예시의 아직 구현하지 않은 함수는 틀로 두되, 완성한 함수를 덮어쓰지 마세요. 채점은 앞 단계의 계약도 함께 봅니다.
- 흔한 실수 두 가지입니다. send 가 돌려준 수를 무시하고 outbox 를 통째로 비우는 것, 그리고 보낼 것이 없는데도 쓰기 준비를 계속 지켜보아 코어 하나가 100퍼센트에 붙는 것입니다.
- 실습 세션이 끝나면 /root 의 파일은 남지 않습니다. 필요한 코드는 끝내기 전에 따로 보관하세요.
단계 8개
- 멈추지 않는 듣는 소켓
- 줄서기를 숫자로 남긴다
- 한 번 깨어났을 때 대기열을 비운다
- 받은 만큼만 읽고 나머지는 보관한다
- 보낸 만큼만 지운다
- 쓰기는 보낼 것이 있을 때만 지켜본다
- 조용한 연결을 고른다
- 한 반복문으로 전부 받고 다시 재서 견준다