LabHub
学习 学习路径 课程

慢的不是一个请求,而是全部

数一数线程池有几个位置

在 LabHub 中继续学习

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

목표

같은 계산이 어디서 도는가 에 따라 루프를 막기도 하고 안 막기도 한다는 것을 직접 재서 확인하고, libuv 스레드 풀의 칸이 몇 개인지를 숫자로 세어 봅니다.

왜 중요한가

crypto.pbkdf2crypto.pbkdf2Sync 는 같은 계산을 합니다. 그런데 하나는 스레드 풀로 가고 하나는 이벤트 루프 위에서 돕니다. 이름 끝의 Sync 네 글자가 "이 요청만 느리다" 와 "전부 느리다" 를 가릅니다.

그리고 스레드 풀은 무한하지 않습니다. 기본이 네 칸이라, 무거운 비동기 작업을 다섯 개 보내면 다섯 번째는 앞의 하나가 끝날 때까지 시작조차 하지 못합니다. 파일 읽기와 DNS 조회와 압축이 같은 네 칸을 나눠 쓰기 때문에, 압축 작업이 파일 읽기를 늦추는 일이 실제로 일어납니다. 반대로 소켓 읽기와 dns.resolve 는 그 칸을 쓰지 않아서 아무 영향을 받지 않습니다. 이 지도를 외우는 것이 아니라 재서 확인하는 법을 익히는 것이 이 실습입니다.

단계

  1. /root/work/blocking/probe.mjsclassify(name) 로 API 의 자리를 적습니다.
  2. 같은 파일에 pct(samples, p)withLag(fn, options) 를 만듭니다.
  3. crypto.pbkdf2Synccrypto.pbkdf2 를 재어 /root/work/blocking/report.jsonruns.pbkdf2Sync·runs.pbkdf2Async 에 적습니다.
  4. 큰 JSON 문자열을 파싱하며 재어 runs.jsonParse 에 적습니다.
  5. /root/work/blocking/wave.mjs 를 만들어 같은 일감 여러 개를 한꺼번에 보내고, runs.pool4runs.pool8 에 적습니다.
  6. UV_THREADPOOL_SIZE=8 로 같은 프로그램을 돌려 runs.pool8big 에 적습니다.
  7. zlib.gzipSynczlib.gzip 으로 같은 방법을 한 번 더 적용합니다.

참고

먼저 지도를 그린다

/root/work/blocking/probe.mjsclassify(name) 를 export 하세요. fs.readFileSync·crypto.pbkdf2Sync·zlib.gzipSync·JSON.parse·JSON.stringify"loop", fs.readFile·crypto.pbkdf2·crypto.randomBytes·zlib.gzip·dns.lookup"threadpool", dns.resolve4·dns.reverse·dns.resolveMx"kernel", 나머지는 "unknown" 입니다.

이 목록은 외운 것이 아니라 공식 문서에 적혀 있습니다 — 명령줄 문서의 UV_THREADPOOL_SIZE 절과 DNS 문서의 '구현상의 고려 사항' 절입니다.

dns.lookupdns.resolve4 가 갈리는 것이 이 표의 핵심입니다. 앞은 운영체제의 getaddrinfo 를 스레드 풀에서 부르고, 뒤는 네트워크로 직접 질의해 스레드 풀을 쓰지 않습니다.

일을 시키면서 재는 자

같은 파일에 pct(samples, p)(1번 실습과 같은 규칙)와 withLag(fn, {durationMs, intervalMs, startAfterMs}) 를 export 하세요. {intervalMs, durationMs, samples, workMs} 를 돌려줍니다.

재기를 먼저 시작하고, startAfterMs 뒤에 fn() 을 부릅니다. fn() 이 프로미스를 돌려주면 기다렸다가 걸린 시간을 workMs 에 담습니다.

일이 끝난 뒤에도 durationMs 가 찰 때까지 계속 재야 합니다. 막힌 구간은 막힘이 풀린 다음 표본에 큰 값으로 나타나기 때문입니다.

같은 계산, 다른 자리

crypto.pbkdf2Synccrypto.pbkdf2(비동기)로 같은 계산을 한 번씩 하며 재어 /root/work/blocking/report.jsonruns.pbkdf2Syncruns.pbkdf2Async 에 적으세요. 각각 samples·p50·p99·max·workMs 를 담고, 최상위에 node 도 적습니다.

한 번에 150ms 이상 걸리게 해야 차이가 보입니다. 반복 횟수를 올리세요 (예: sha512 로 300000회).

두 경우의 workMs 는 비슷하게 나옵니다 — 일의 양은 같기 때문입니다. 달라지는 것은 그동안 루프가 무엇을 할 수 있었는가입니다.

옮길 곳이 없는 일

4MB 가 넘는 JSON 문자열을 만들어 JSON.parse 로 파싱하며 재고, runs.jsonParsebytes 와 함께 적으세요.

JSON.parse 에는 비동기판이 없습니다. fs.readFile 로 파일을 비동기로 읽어도 파싱은 루프 위에서 일어납니다.

그래서 큰 JSON 을 받는 엔드포인트는 본문 크기 제한이 곧 지연 예산이 됩니다. 여기서 잰 숫자가 그 제한을 정하는 근거가 됩니다.

칸이 몇 개인지 세어 본다

/root/work/blocking/wave.mjs 를 만드세요. process.argv[2] 개의 crypto.pbkdf2(비동기)를 한꺼번에 보내고, 끝난 시각(ms)을 오름차순으로 정렬해 {n, threadpoolSize, finishMs} 한 줄을 찍습니다. threadpoolSizeprocess.env.UV_THREADPOOL_SIZE 가 없으면 4 입니다. 4개와 8개로 돌린 결과를 runs.pool4·runs.pool8 에 적고, runs.pool8.waveRatiofinishMs[7] / finishMs[3] 을 적습니다.

일감 하나가 150ms 이상 걸려야 칸이 갈리는 것이 보입니다.

네 개는 거의 같은 시각에 끝나는데 여덟 개는 두 덩어리로 갈라집니다. 뒤 네 개는 앞 네 개가 칸을 비워 줄 때까지 시작도 못 한 것입니다.

칸을 늘리면 해결되는가

UV_THREADPOOL_SIZE=8 을 주고 같은 wave.mjs 를 8개로 돌려 runs.pool8big 에 적으세요. threadpoolSize·finishMs·waveRatio 와 함께, speeduppool8.finishMs[7] / pool8big.finishMs[7] 을 적습니다.

환경 변수는 프로그램을 시작할 때 주어야 합니다 — UV_THREADPOOL_SIZE=8 node wave.mjs 8.

물결은 사라집니다. 그런데 전체가 끝나는 시각을 보세요. 이 파드의 CPU 는 2코어뿐이라, 늘어난 것은 동시에 시작할 수 있는 일감의 수이지 계산을 해 주는 손의 수가 아닙니다.

새 API 에 같은 방법을 적용한다

4MB 가 넘는 버퍼를 만들어 zlib.gzipSynczlib.gzip(비동기)으로 한 번씩 압축하며 재고, runs.gzipSync·runs.gzipAsyncbytes 와 함께 적으세요.

1단계의 표에서 zlib 의 자리를 먼저 확인하고, 그 표가 맞는지 재서 확인하는 순서입니다. 처음 보는 라이브러리를 만났을 때 하는 일이 정확히 이것입니다.

잘 눌리는 자료는 금방 끝나 차이가 안 보입니다. crypto.randomBytes 로 만든 버퍼를 쓰면 압축기가 실제로 일을 합니다.