LabHub
开始
学习 学习路径 课程

实时通信 — WebSocket、gRPC 流式调用与 WebRTC

动手实现连接池并让它耗尽

在 LabHub 中继续学习

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

목표

상한·대기 시간·폐기 규칙·유휴 한도를 갖춘 클라이언트 커넥션 풀을 직접 만들고, 요청 타임아웃이 없는 느린 호출이 풀을 말리는 장면과 되살아나는 장면을 같은 측정기로 잽니다.

왜 중요한가

HTTP 클라이언트, DB 드라이버, gRPC 채널은 모두 안에 풀을 갖고 있습니다. 풀이 마르면 서버는 한가한데 클라이언트의 모든 요청이 줄을 서고, 서버 지표만 보는 사람에게는 아무것도 보이지 않습니다. 원인은 대개 풀 설정이 아니라 풀 바깥의 규칙입니다 — 요청 타임아웃이 없거나, 타임아웃 난 연결을 돌려놓거나, 상대가 이미 닫은 연결을 다시 씁니다. 이 실습은 그 세 가지를 하나씩 재현하고 막습니다. 표준 라이브러리만 씁니다.

단계

  1. 상한이 있는 풀 — /root/rt/pool/pool.py 에 PoolTimeout 예외와 Pool 클래스를 만드세요. Pool(factory, max_size, acquire_timeout, idle_timeout=None) 은 인자 없이 부르면 연결을 하나 만들어 주는 factory 를 받습니다. acquire() 는 쉬는 연결이 있으면 그것을, 없고 지금까지 만든 연결이 max_size 보다 적으면 factory 로 새로 만들어 돌려줍니다. release(conn) 은 연결을 쉬는 목록에 돌려놓습니다. max_size 가 1 이상의 int 가 아니거나 acquire_timeout 이 양수가 아니면 ValueError 이고, bool 은 수로 받지 않습니다.
  2. 빈자리가 없으면 정해진 만큼만 기다린다 — acquire() 를 고쳐, 연결이 모두 쓰이고 있고 더 만들 수도 없으면 acquire_timeout 초까지 기다리게 하세요. 그 사이에 누가 release 하면 기다리던 쪽이 곧바로 그 연결을 받고, 시간이 다 되면 PoolTimeout 을 냅니다. 기다리는 동안 CPU 를 쓰며 돌지 마세요.
  3. 상태를 모르는 연결은 돌려놓지 않는다 — Pool 에 release(conn, broken=False) 의 broken 을 더하고 connection() 컨텍스트 관리자를 추가하세요. broken=True 면 연결의 close() 를 부르고 풀에서 빼서, 다음 acquire 가 새 연결을 만들 수 있게 합니다. with pool.connection() as conn: 블록이 정상으로 끝나면 연결을 돌려놓고, 예외로 끝나면 broken 으로 처리한 뒤 그 예외를 그대로 다시 냅니다.
  4. 풀의 상태를 숫자로 내놓는다 — Pool 에 stats() 를 추가하세요. size(지금 살아 있는 연결 수), idle(쉬는 연결 수), in_use(빌려 간 연결 수), waiting(지금 acquire 에서 기다리는 호출 수), timeouts(지금까지 PoolTimeout 을 낸 횟수) 다섯 키의 dict 를 돌려줍니다.
  5. 타임아웃 난 연결에는 지난 응답이 남아 있다 — /root/rt/pool/pool.py 에 get(pool, path, timeout) 을 추가하세요. pool 에서 연결(소켓)을 빌려 settimeout(timeout) 을 걸고, /opt/fixtures/rt/rtnet.py 의 h1_get(sock, path) 로 HTTP/1.1 GET 을 보내 (상태, 본문) 을 돌려줍니다. 타임아웃을 포함해 어떤 예외든 나면 그 연결은 broken 으로 버리고 예외를 다시 냅니다. 채점기는 max_size 1 인 풀로 느린 요청을 타임아웃시킨 뒤 곧바로 다음 요청을 보냅니다.
  6. 느린 호출 넷이 풀 전체를 붙잡는다 — /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py 를 돌리세요. 크기 4 인 여러분의 풀로, 5초 걸리는 요청 넷을 먼저 보낸 뒤 빠른 요청 스무 개를 보냅니다. 요청 타임아웃이 없을 때와 0.5초일 때 빠른 요청이 몇 개 성공했는지가 나옵니다. 출력의 starved_ok 와 bounded_ok 두 줄을 /root/rt/pool/report.txt 에 적으세요. 채점기가 다시 재서 대조합니다.
  7. 서버가 먼저 닫은 연결을 다시 쓰지 않는다 — Pool 의 idle_timeout 을 구현하세요. release 로 돌아온 시각을 기록해 두고, acquire 가 쉬는 연결을 꺼낼 때 쉰 시간이 idle_timeout 초를 넘은 것은 close() 하고 버린 뒤 다음 것을 봅니다. None 이면 버리지 않습니다. 채점기는 1초 동안 조용한 연결을 끊는 중계기 뒤에 서버를 두고, idle_timeout 0.5 인 풀로 요청 → 1.5초 쉼 → 요청을 보냅니다.

참고

상한이 있는 풀

/root/rt/pool/pool.py 에 PoolTimeout 예외와 Pool 클래스를 만드세요. Pool(factory, max_size, acquire_timeout, idle_timeout=None) 은 인자 없이 부르면 연결을 하나 만들어 주는 factory 를 받습니다. acquire() 는 쉬는 연결이 있으면 그것을, 없고 지금까지 만든 연결이 max_size 보다 적으면 factory 로 새로 만들어 돌려줍니다. release(conn) 은 연결을 쉬는 목록에 돌려놓습니다. max_size 가 1 이상의 int 가 아니거나 acquire_timeout 이 양수가 아니면 ValueError 이고, bool 은 수로 받지 않습니다.

쉬는 연결을 먼저 쓰는 것이 풀의 존재 이유입니다. 연결을 만드는 비용(TCP 핸드셰이크, TLS)을 요청마다 치르지 않으려는 것입니다. factory 가 몇 번 불렸는지를 채점기가 셉니다.

빈자리가 없으면 정해진 만큼만 기다린다

acquire() 를 고쳐, 연결이 모두 쓰이고 있고 더 만들 수도 없으면 acquire_timeout 초까지 기다리게 하세요. 그 사이에 누가 release 하면 기다리던 쪽이 곧바로 그 연결을 받고, 시간이 다 되면 PoolTimeout 을 냅니다. 기다리는 동안 CPU 를 쓰며 돌지 마세요.

threading.Condition 의 wait(timeout) 은 깨어난 이유를 알려 주지 않습니다. 깨어날 때마다 조건을 다시 보고, 남은 시간을 다시 계산해야 합니다. 마감 시각을 time.monotonic() 으로 한 번 정해 두면 계산이 단순해집니다.

상태를 모르는 연결은 돌려놓지 않는다

Pool 에 release(conn, broken=False) 의 broken 을 더하고 connection() 컨텍스트 관리자를 추가하세요. broken=True 면 연결의 close() 를 부르고 풀에서 빼서, 다음 acquire 가 새 연결을 만들 수 있게 합니다. with pool.connection() as conn: 블록이 정상으로 끝나면 연결을 돌려놓고, 예외로 끝나면 broken 으로 처리한 뒤 그 예외를 그대로 다시 냅니다.

예외가 난 순간 그 연결에 무엇이 남아 있는지 모릅니다. 응답의 뒷부분이 아직 소켓에 있을 수도 있습니다. 모르는 것은 버리는 것이 풀의 기본 규칙입니다. contextlib.contextmanager 를 쓰면 짧게 쓸 수 있습니다.

풀의 상태를 숫자로 내놓는다

Pool 에 stats() 를 추가하세요. size(지금 살아 있는 연결 수), idle(쉬는 연결 수), in_use(빌려 간 연결 수), waiting(지금 acquire 에서 기다리는 호출 수), timeouts(지금까지 PoolTimeout 을 낸 횟수) 다섯 키의 dict 를 돌려줍니다.

풀 고갈은 서버 지표에 보이지 않습니다. 서버는 한가하고 클라이언트만 줄을 서 있기 때문입니다. waiting 과 timeouts 가 그 줄을 보여 주는 유일한 숫자입니다. 기다리기 시작할 때 올리고 어떤 이유로든 기다림이 끝날 때 내리세요.

타임아웃 난 연결에는 지난 응답이 남아 있다

/root/rt/pool/pool.py 에 get(pool, path, timeout) 을 추가하세요. pool 에서 연결(소켓)을 빌려 settimeout(timeout) 을 걸고, /opt/fixtures/rt/rtnet.py 의 h1_get(sock, path) 로 HTTP/1.1 GET 을 보내 (상태, 본문) 을 돌려줍니다. 타임아웃을 포함해 어떤 예외든 나면 그 연결은 broken 으로 버리고 예외를 다시 냅니다. 채점기는 max_size 1 인 풀로 느린 요청을 타임아웃시킨 뒤 곧바로 다음 요청을 보냅니다.

타임아웃은 기다리기를 그만뒀다는 뜻이지 서버가 응답을 안 보낸다는 뜻이 아닙니다. 그 응답은 늦게라도 같은 소켓으로 도착합니다. 그 소켓을 풀에 돌려놓으면 다음 요청이 남의 응답을 읽습니다. sys.path 에 /opt/fixtures/rt 를 넣으면 rtnet 을 가져다 쓸 수 있습니다.

느린 호출 넷이 풀 전체를 붙잡는다

/opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py 를 돌리세요. 크기 4 인 여러분의 풀로, 5초 걸리는 요청 넷을 먼저 보낸 뒤 빠른 요청 스무 개를 보냅니다. 요청 타임아웃이 없을 때와 0.5초일 때 빠른 요청이 몇 개 성공했는지가 나옵니다. 출력의 starved_ok 와 bounded_ok 두 줄을 /root/rt/pool/report.txt 에 적으세요. 채점기가 다시 재서 대조합니다.

타임아웃이 없는 호출은 풀의 자리를 무한정 차지합니다. acquire_timeout 은 기다리는 쪽을 구할 뿐 자리를 차지한 쪽을 끝내지 못합니다. 두 값이 왜 그렇게 나오는지 설명할 수 있어야 합니다.

서버가 먼저 닫은 연결을 다시 쓰지 않는다

Pool 의 idle_timeout 을 구현하세요. release 로 돌아온 시각을 기록해 두고, acquire 가 쉬는 연결을 꺼낼 때 쉰 시간이 idle_timeout 초를 넘은 것은 close() 하고 버린 뒤 다음 것을 봅니다. None 이면 버리지 않습니다. 채점기는 1초 동안 조용한 연결을 끊는 중계기 뒤에 서버를 두고, idle_timeout 0.5 인 풀로 요청 → 1.5초 쉼 → 요청을 보냅니다.

로드밸런서와 서버는 조용한 연결을 먼저 끊습니다. 클라이언트 풀은 그 사실을 다음에 그 연결로 보낼 때에야 압니다. 풀의 유휴 한도를 상대의 유휴 한도보다 짧게 두면 그 경주를 피할 수 있습니다.