LabHub
배우기 러닝패스 코스

One Slow Connection Froze Every Other One

Serve hundreds of connections from one loop

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

순차 서버의 줄서기를 직접 재고, 같은 부하를 한 반복문으로 받아 내는 다중화 서버를 만들어 두 측정값을 견줍니다.

왜 중요한가

연결 하나가 말을 늦게 시작하면 순차 서버는 그동안 아무 일도 하지 못합니다. 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 에 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 로 받지 않습니다.

bind 와 listen 은 커널의 상태를 바꿀 뿐 상대를 기다리지 않습니다. 프로세스를 세우는 것은 accept 이고, 그것을 막는 한 줄이 소켓의 모드입니다. 검사에서는 bool 이 int 의 하위 타입이라는 점을 따로 다뤄야 합니다.

줄서기를 숫자로 남긴다

python3 /opt/fixtures/mux/probe.py --target sequential 을 돌리세요. 출력 끝의 blocked 와 fast 두 값을 /root/mux/report.txt 에 seq_blocked=<값> 과 seq_fast=<값> 두 줄로 적습니다. 채점기가 같은 측정을 그 자리에서 다시 돌려 두 값을 대조합니다.

측정기는 느린 손님 한 명을 먼저 붙이고 빠른 손님 여럿을 뒤에 세웁니다. blocked 는 1초를 넘긴 손님 수, fast 는 0.2초 안에 끝난 손님 수입니다. 짐작해서 적으면 다시 잰 값과 어긋납니다. 출력을 파일로 남겨 두면 마지막 단계에서 견주기 쉽습니다.

한 번 깨어났을 때 대기열을 비운다

server.py 에 accept_all(listener) 를 추가하세요. BlockingIOError 가 날 때까지 accept 를 반복해 받아들인 소켓의 list 를 돌려줍니다. 기다리는 연결이 없으면 빈 list 이고, 받아들인 소켓도 setblocking(False) 로 바꿉니다. ConnectionAbortedError 나 ConnectionResetError 가 난 연결은 건너뛰고 남은 대기열은 계속 받습니다.

읽기 준비 통지는 기다리는 연결이 하나라고 말해 주지 않습니다. 몇 개인지 알려 주는 값도 없으므로, 더 없다는 신호가 올 때까지 반복하는 것이 유일한 방법입니다. 그 신호는 오류가 아니라 정상 종료 조건입니다.

받은 만큼만 읽고 나머지는 보관한다

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 인 줄도 줄 하나입니다.

한 번의 recv 가 한 메시지를 준다는 보장은 없습니다. 앞 조각과 이어 붙인 뒤에야 개행을 찾을 수 있고, 개행이 없다는 것은 잘못된 입력이 아니라 아직 다 오지 않았다는 뜻입니다. b"" 와 빈 줄은 값은 비슷해 보여도 뜻이 정반대입니다.

보낸 만큼만 지운다

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 입니다.

send 의 반환값은 이번 호출이 처리한 바이트 수입니다. 짧은 응답으로 시험하면 대개 한 번에 다 나가서 잘못된 구현도 통과합니다. 20바이트를 넣고 3만 나갔을 때 outbox 에 무엇이 남아야 하는지부터 정하세요.

쓰기는 보낼 것이 있을 때만 지켜본다

server.py 에 interest(conn) 을 추가하세요. conn 의 outbox 가 비어 있으면 selectors.EVENT_READ 하나만, 남아 있으면 selectors.EVENT_READ 와 selectors.EVENT_WRITE 를 OR 로 합친 값을 돌려줍니다.

읽기 준비와 쓰기 준비는 참이 되는 빈도가 전혀 다릅니다. 송신 버퍼는 대부분의 시간에 비어 있으므로 쓰기 준비는 거의 항상 참입니다. 그 사실이 이 함수의 조건을 정합니다.

조용한 연결을 고른다

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 은 받지 않습니다.

고르는 일과 끊는 일을 한 함수에 섞으면 순회 도중에 자료구조가 바뀝니다. 고르는 쪽을 순수 함수로 두면 시계를 넣어 주는 것만으로 경계 조건을 시험할 수 있습니다. 경과가 제한과 정확히 같을 때 어느 쪽으로 할지 먼저 정하세요.

한 반복문으로 전부 받고 다시 재서 견준다

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=<값> 세 줄을 더하세요.

앞 단계에서 만든 함수를 그대로 이어 붙이면 됩니다. 반복문이 새로 정하는 것은 순서뿐입니다. 받아들이고, 읽고, 쓰고, 관심 이벤트를 다시 계산하고, 끊을 것을 끊습니다. fds 값은 구현에 딸린 숫자라 남의 답과 다를 수 있고, 왜 200 이 아닌지 설명할 수 있어야 합니다.