LabHub
开始
学习 学习路径 课程

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

在一个 HTTP/2 连接上叠加请求并测量代价

在 LabHub 中继续学习

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

목표

HTTP/2 프레임과 헤더 압축을 바이트 수준에서 확인하고, 한 연결에 요청을 겹쳐 싣는 클라이언트를 만들어 HTTP/1.1 과의 차이와 TCP 수준 HOL 블로킹을 직접 잽니다.

왜 중요한가

HTTP/2 는 연결 하나로 요청 여럿을 동시에 나르려고 만든 프로토콜입니다. 그 대신 한 연결에 모든 것을 걸게 되어, 흐름 제어 창을 돌려주지 않는 버그 하나나 세그먼트 손실 하나가 그 연결의 모든 스트림을 세웁니다. gRPC 는 HTTP/2 위에서 돌고, 스트리밍 응답이 갑자기 멈추는 장애의 상당수가 이 창에서 납니다. 이 실습은 이득과 비용을 같은 도구로 재서, 어느 쪽이 언제 이기는지를 숫자로 남깁니다.

단계

  1. 9바이트 프레임 머리를 읽는다 — /root/rt/h2/h2lab.py 에 frame_header(data) 를 만드세요. bytes 앞 9바이트를 HTTP/2 프레임 머리로 읽어 (길이, 종류, 플래그, 스트림 번호) 네 int 의 tuple 을 돌려줍니다. 길이는 앞 3바이트의 부호 없는 빅엔디언 정수이고, 스트림 번호는 마지막 4바이트에서 맨 앞 예약 비트를 뺀 31비트입니다. 9바이트보다 짧으면 ValueError 이며, 9바이트 뒤에 붙은 바이트는 무시합니다.
  2. 같은 머리를 두 번 보내면 작아진다 — /root/rt/h2/h2lab.py 에 hpack_sizes(headers, times) 를 추가하세요. (이름, 값) tuple 의 list 인 headers 를 hpack.Encoder 로 times 번 부호화하고, 각 부호화 결과의 바이트 수를 list 로 돌려줍니다. 한 연결이 요청을 여러 번 보내는 상황을 흉내 내는 것이므로 인코더는 하나만 만들어 계속 씁니다.
  3. 한 연결에 요청을 겹쳐 싣는다 — /root/rt/h2/h2lab.py 에 fetch_all(host, port, paths, timeout=10) 을 추가하세요. host:port 로 TCP 연결 하나를 열고 h2 라이브러리의 클라이언트 연결로 서문을 보낸 뒤, paths 의 GET 요청을 응답을 기다리지 말고 모두 보냅니다. 응답이 끝날 때마다 모아 paths 와 같은 순서로 (경로, 상태 int, 본문 bytes, 시작부터 끝날 때까지 밀리초 float) tuple 의 list 를 돌려줍니다. 받은 DATA 는 acknowledge_received_data 로 창을 돌려주고, timeout 초 안에 끝나지 않으면 예외를 냅니다.
  4. HTTP/1.1 과 같은 자로 잰다 — /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py compare 를 돌리세요. 300ms 걸리는 요청 여섯 개를 HTTP/1.1 keep-alive 연결 하나로 차례로 보낸 시간과 여러분의 fetch_all 로 보낸 시간이 나옵니다. 출력의 h1_total_ms 와 h2_total_ms 두 줄을 /root/rt/h2/report.txt 에 그대로 적으세요. 채점기가 그 자리에서 다시 재서 대조합니다.
  5. 서버가 광고한 동시 스트림 한도를 지킨다 — fetch_all 을 고쳐, 서버의 SETTINGS 를 받은 뒤 remote_settings.max_concurrent_streams 를 넘지 않게 스트림을 열게 하세요. 한도가 찼으면 나머지 요청은 기다렸다가 스트림이 끝나는 대로 엽니다. 채점기는 한도를 2 로 광고하는 서버에 요청 다섯 개를 보내, 한도를 넘기지 않으면서도 두 개씩 겹쳐 보내는지 봅니다.
  6. 흐름 제어 창이 닫히는 자리를 잰다 — /root/rt/h2/h2lab.py 에 window_probe(host, port, path, window, quiet=0.3) 를 추가하세요. 새 연결의 서문 뒤에 INITIAL_WINDOW_SIZE 를 window 로 알리는 SETTINGS 를 보내고 GET path 를 보냅니다. 받은 DATA 는 창을 돌려주지 않고 세다가 quiet 초 동안 아무것도 오지 않으면 그때까지 받은 바이트를 기록하고, 받은 만큼 창을 돌려준 뒤 끝까지 읽습니다. (멈추기 전까지 받은 바이트, 전체 바이트) 를 돌려줍니다.
  7. TCP 한 줄이 막히면 스트림이 전부 선다 — /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py hol 을 돌리세요. 하류로 20000 바이트가 지나간 뒤 한 번 800ms 멈추는 중계기를 사이에 두고, 큰 응답과 작은 응답을 함께 요청해 작은 응답이 끝난 시각을 잽니다. HTTP/2 는 여러분의 fetch_all 로 한 연결에, HTTP/1.1 은 연결 둘로 나눠 보냅니다. 출력의 hol_h2_ms 와 hol_h1_ms 두 줄을 /root/rt/h2/report.txt 에 더하세요.

참고

9바이트 프레임 머리를 읽는다

/root/rt/h2/h2lab.py 에 frame_header(data) 를 만드세요. bytes 앞 9바이트를 HTTP/2 프레임 머리로 읽어 (길이, 종류, 플래그, 스트림 번호) 네 int 의 tuple 을 돌려줍니다. 길이는 앞 3바이트의 부호 없는 빅엔디언 정수이고, 스트림 번호는 마지막 4바이트에서 맨 앞 예약 비트를 뺀 31비트입니다. 9바이트보다 짧으면 ValueError 이며, 9바이트 뒤에 붙은 바이트는 무시합니다.

프레임 머리의 모양은 RFC 9113 4.1절 그림 그대로입니다. 예약 비트는 보낼 때 0 이어야 하지만 받을 때는 무시하라고 되어 있습니다. 켜진 채로 도착해도 스트림 번호가 달라지면 안 됩니다.

같은 머리를 두 번 보내면 작아진다

/root/rt/h2/h2lab.py 에 hpack_sizes(headers, times) 를 추가하세요. (이름, 값) tuple 의 list 인 headers 를 hpack.Encoder 로 times 번 부호화하고, 각 부호화 결과의 바이트 수를 list 로 돌려줍니다. 한 연결이 요청을 여러 번 보내는 상황을 흉내 내는 것이므로 인코더는 하나만 만들어 계속 씁니다.

HPACK 의 동적 테이블은 연결마다 하나이고 연결이 살아 있는 동안 쌓입니다. 인코더 객체가 곧 그 테이블입니다. 두 번째 결과가 첫 번째와 같다면 무엇을 새로 만들고 있는지 보세요.

한 연결에 요청을 겹쳐 싣는다

/root/rt/h2/h2lab.py 에 fetch_all(host, port, paths, timeout=10) 을 추가하세요. host:port 로 TCP 연결 하나를 열고 h2 라이브러리의 클라이언트 연결로 서문을 보낸 뒤, paths 의 GET 요청을 응답을 기다리지 말고 모두 보냅니다. 응답이 끝날 때마다 모아 paths 와 같은 순서로 (경로, 상태 int, 본문 bytes, 시작부터 끝날 때까지 밀리초 float) tuple 의 list 를 돌려줍니다. 받은 DATA 는 acknowledge_received_data 로 창을 돌려주고, timeout 초 안에 끝나지 않으면 예외를 냅니다.

h2 는 소켓을 모릅니다. data_to_send() 로 나갈 바이트를 꺼내 직접 보내고, 받은 바이트는 receive_data() 에 넣어 이벤트로 돌려받습니다. 창을 돌려주지 않으면 큰 응답 하나가 64KiB 에서 연결 전체를 세웁니다. 채점 요청에는 200000 바이트짜리 응답이 섞여 있습니다.

HTTP/1.1 과 같은 자로 잰다

/opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py compare 를 돌리세요. 300ms 걸리는 요청 여섯 개를 HTTP/1.1 keep-alive 연결 하나로 차례로 보낸 시간과 여러분의 fetch_all 로 보낸 시간이 나옵니다. 출력의 h1_total_ms 와 h2_total_ms 두 줄을 /root/rt/h2/report.txt 에 그대로 적으세요. 채점기가 그 자리에서 다시 재서 대조합니다.

HTTP/1.1 은 한 연결에서 응답 순서대로만 요청을 처리합니다. 파이프라이닝은 명세에 있지만 브라우저와 프록시가 사실상 쓰지 않습니다. 그래서 여섯 번의 300ms 가 그대로 더해집니다.

서버가 광고한 동시 스트림 한도를 지킨다

fetch_all 을 고쳐, 서버의 SETTINGS 를 받은 뒤 remote_settings.max_concurrent_streams 를 넘지 않게 스트림을 열게 하세요. 한도가 찼으면 나머지 요청은 기다렸다가 스트림이 끝나는 대로 엽니다. 채점기는 한도를 2 로 광고하는 서버에 요청 다섯 개를 보내, 한도를 넘기지 않으면서도 두 개씩 겹쳐 보내는지 봅니다.

SETTINGS 를 받기 전에는 한도를 모릅니다. 명세상 그때의 기본값은 무제한이라 서문을 보내자마자 요청을 몰아 보내면 서버가 거절합니다. 첫 SETTINGS 는 RemoteSettingsChanged 이벤트로 옵니다.

흐름 제어 창이 닫히는 자리를 잰다

/root/rt/h2/h2lab.py 에 window_probe(host, port, path, window, quiet=0.3) 를 추가하세요. 새 연결의 서문 뒤에 INITIAL_WINDOW_SIZE 를 window 로 알리는 SETTINGS 를 보내고 GET path 를 보냅니다. 받은 DATA 는 창을 돌려주지 않고 세다가 quiet 초 동안 아무것도 오지 않으면 그때까지 받은 바이트를 기록하고, 받은 만큼 창을 돌려준 뒤 끝까지 읽습니다. (멈추기 전까지 받은 바이트, 전체 바이트) 를 돌려줍니다.

흐름 제어 창은 스트림마다 하나, 연결 전체에 하나 있습니다. SETTINGS 로 바꿀 수 있는 것은 스트림의 초깃값뿐이고 연결 창은 65535 에서 시작합니다. window 를 100000 으로 주면 어디서 멈출지 먼저 계산해 보세요.

TCP 한 줄이 막히면 스트림이 전부 선다

/opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py hol 을 돌리세요. 하류로 20000 바이트가 지나간 뒤 한 번 800ms 멈추는 중계기를 사이에 두고, 큰 응답과 작은 응답을 함께 요청해 작은 응답이 끝난 시각을 잽니다. HTTP/2 는 여러분의 fetch_all 로 한 연결에, HTTP/1.1 은 연결 둘로 나눠 보냅니다. 출력의 hol_h2_ms 와 hol_h1_ms 두 줄을 /root/rt/h2/report.txt 에 더하세요.

중계기가 멈추는 것은 손실된 세그먼트 하나가 재전송을 기다리는 동안 TCP 가 뒤의 바이트를 애플리케이션에 올려 주지 않는 모습을 흉내 낸 것입니다. 스트림은 HTTP/2 의 개념이고 TCP 는 그것을 모릅니다.