LabHub
배우기 러닝패스 코스

遅くなったのは一つではなく全部だった

大きな計算を刻み、ワーカーへ移す

LabHub 에서 이어서 보기

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

목표

루프를 오래 붙잡는 계산을 두 가지 방법으로 다룹니다 — 조각으로 나눠 사이사이 차례를 돌려주기, 그리고 워커 스레드로 통째로 옮기기. 그리고 각각의 값을 숫자로 잽니다.

왜 중요한가

"무거우면 워커로 보내라" 는 조언은 반쪽입니다. 워커는 메모리를 공유하지 않아서 지금 요청의 객체를 그대로 볼 수 없고, 스레드를 하나 띄우는 데도 시간이 듭니다. 그 시간이 일 자체보다 크면 옮기는 것이 손해입니다.

쪼개기는 반대입니다. 같은 스레드에 남아 있으니 상태를 그대로 보고, 대신 조각 사이에 양보하는 값을 총 시간으로 치릅니다. 그리고 조각 하나의 시간이 곧 지연의 바닥이 됩니다 — 조각을 도는 동안에는 아무도 끼어들 수 없기 때문입니다.

두 값을 재 두면 "이 일을 어디에 둘까" 가 취향이 아니라 계산이 됩니다. 마지막 단계에서 그 계산을 규칙으로 굳힙니다.

단계

  1. /root/work/worker/split.mjschunks(total, size) 를 만듭니다.
  2. 같은 파일에 hashRange(from, to, seed)runSliced(total, sliceSize) 를 만듭니다.
  3. 한 번에 도는 쪽과 쪼갠 쪽을 재어 /root/work/worker/report.jsonruns.whole·runs.sliced 에 적습니다.
  4. 두 벌에서 slicing.costRatio·tailRatio·sliceMs 를 계산해 적습니다.
  5. /root/work/worker/hash-worker.mjs 를 만들고 runInWorker(total) 로 옮깁니다.
  6. 워커가 도는 동안을 재어 runs.worker 에 적고, worker.startupMs 도 적습니다.
  7. place(job) 으로 "어디에 둘까" 를 규칙으로 만듭니다.

참고

경계에서 흘리지 않게 나눈다

/root/work/worker/split.mjschunks(total, size) 를 export 하세요. {from, to} 의 배열이고, 마지막 조각은 짧을 수 있습니다. size 가 0 이하면 예외를 던집니다.

to그 자리를 포함하지 않습니다. 그래야 다음 조각의 from 과 그대로 이어집니다.

조각 수는 Math.ceil(total / size) 이고, 이어 붙이면 틈도 겹침도 없이 0 부터 total 까지를 덮어야 합니다.

쪼개고, 사이에 차례를 돌려준다

같은 파일에 hashRange(from, to, seed = 0)runSliced(total, sliceSize) 를 export 하세요. runSliced{result, slices} 를 돌려주고, 조각 사이마다 루프에 한 번 양보합니다.

h = (h * 31 + i) >>> 0 을 돌리되, 앞 조각의 결과를 다음 조각의 seed 로 넘기세요. 안 넘기면 답이 달라집니다.

양보는 await new Promise(r => setImmediate(r)) 한 줄입니다. 나누기만 하고 이 줄이 없으면 루프에서 보면 한 번에 돈 것과 똑같습니다 — 채점기는 그 사이에 타이머가 몇 번 돌았는지를 봅니다.

같은 답, 다른 분포

4천만 번 도는 계산을 (가) 한 번에 (나) 조각으로 나눠 각각 재고, /root/work/worker/report.jsonnode·totalruns.whole·runs.sliced 를 적으세요. 두 벌 모두 samples·p50·p99·max·workMs·result 를 담고, runs.sliced 에는 slices 도 담습니다.

재는 자는 2번 실습의 withLag 과 같은 모양으로 이 파일에도 하나 두면 됩니다.

result 가 두 벌에서 같아야 합니다. 다르다면 조각 사이에 seed 를 안 넘긴 것입니다 — 쪼개기는 답을 바꾸지 않습니다.

쪼개기의 값을 계산한다

slicingcostRatio(sliced.workMs / whole.workMs, 소수점 셋째 자리), tailRatio(whole.max / sliced.max, 둘째 자리), sliceMs(sliced.workMs / sliced.slices, 셋째 자리)를 적으세요.

costRatio 는 1 보다 조금 큽니다 — 양보 한 번마다 루프를 한 바퀴 도는 값입니다.

sliceMs 가 중요합니다. 조각 하나를 도는 동안에는 아무도 끼어들 수 없으므로, 목표 지연을 정하면 조각 크기가 따라 나옵니다. 50ms 안에 응답하고 싶으면 조각 하나가 50ms 를 넘으면 안 됩니다.

다른 스레드로 옮긴다

/root/work/worker/hash-worker.mjs 를 만들고(parentPort 로 결과를 돌려보냅니다), split.mjsrunInWorker(total) 을 export 하세요. hashRange(0, total) 과 같은 값을 프로미스로 돌려주고, 동시에 두 번 불러도 되어야 합니다.

워커는 메모리를 공유하지 않습니다. 필요한 값은 workerData 로 건네고 결과는 postMessage 로 돌려받습니다.

그래서 "지금 요청의 객체를 워커에서 고쳐 달라" 는 안 됩니다. 복사해서 보내고 복사해서 받을 뿐입니다 — 옮길 수 있는 일과 없는 일이 여기서 갈립니다.

워커의 값을 계산한다

워커가 도는 동안을 재어 runs.workersamples·p50·p99·max·workMs·result 를 적고, spawnCostMs() 를 export 해 그 값을 worker.startupMs 에 적으세요.

워커가 도는 동안 이 스레드의 지연은 거의 0 에 머뭅니다 — 계산이 여기 없기 때문입니다. 그 대신 startupMs 를 보세요.

기동 비용이 일 자체보다 크면 옮기는 것이 손해입니다. 그래서 실제 서비스에서는 워커를 미리 몇 개 띄워 두고 재사용합니다 — 이 숫자가 그 이유입니다.

규칙으로 굳힌다

place(job) 을 export 하세요. job.cpuMs 가 1 이하면 "loop", 그보다 크고 job.sharedState 가 참이면 "slice", 아니면 cpuMs 가 50 이상일 때 "worker" 이고 그 미만은 "slice" 입니다. cpuMs 가 없거나 숫자가 아니면 예외를 던집니다.

숫자가 없는 일감에 아무 답이나 돌려주는 것이 제일 위험합니다. "재지 않았다" 와 "가볍다" 는 다릅니다.

50 이라는 경계는 앞 단계에서 잰 기동 비용에서 나왔습니다. 다른 기계에서는 다른 값이 나오고, 그러면 이 상수도 바뀌어야 합니다 — 규칙은 측정에 딸린 것입니다.