大きな計算を刻み、ワーカーへ移す
한국어 원문으로 표시합니다.
목표
루프를 오래 붙잡는 계산을 두 가지 방법으로 다룹니다 — 조각으로 나눠 사이사이 차례를 돌려주기, 그리고 워커 스레드로 통째로 옮기기. 그리고 각각의 값을 숫자로 잽니다.
왜 중요한가
"무거우면 워커로 보내라" 는 조언은 반쪽입니다. 워커는 메모리를 공유하지 않아서 지금 요청의 객체를 그대로 볼 수 없고, 스레드를 하나 띄우는 데도 시간이 듭니다. 그 시간이 일 자체보다 크면 옮기는 것이 손해입니다.
쪼개기는 반대입니다. 같은 스레드에 남아 있으니 상태를 그대로 보고, 대신 조각 사이에 양보하는 값을 총 시간으로 치릅니다. 그리고 조각 하나의 시간이 곧 지연의 바닥이 됩니다 — 조각을 도는 동안에는 아무도 끼어들 수 없기 때문입니다.
두 값을 재 두면 "이 일을 어디에 둘까" 가 취향이 아니라 계산이 됩니다. 마지막 단계에서 그 계산을 규칙으로 굳힙니다.
단계
/root/work/worker/split.mjs에chunks(total, size)를 만듭니다.- 같은 파일에
hashRange(from, to, seed)와runSliced(total, sliceSize)를 만듭니다. - 한 번에 도는 쪽과 쪼갠 쪽을 재어
/root/work/worker/report.json의runs.whole·runs.sliced에 적습니다. - 두 벌에서
slicing.costRatio·tailRatio·sliceMs를 계산해 적습니다. /root/work/worker/hash-worker.mjs를 만들고runInWorker(total)로 옮깁니다.- 워커가 도는 동안을 재어
runs.worker에 적고,worker.startupMs도 적습니다. place(job)으로 "어디에 둘까" 를 규칙으로 만듭니다.
참고
chunks(total, size)는{from, to}객체의 배열입니다.size가 0 이하면 예외를 던지세요 — 그대로 두면 영원히 도는 반복이 됩니다.hashRange(from, to, seed = 0)은h = (h * 31 + i) >>> 0을from부터to앞까지 돌린 값입니다. 순서에 기대는 계산이라 조각 사이에 앞 값을 seed 로 넘겨야 답이 같습니다.runSliced(total, sliceSize)는{result, slices}를 돌려줍니다.runInWorker(total)는hashRange(0, total)과 같은 값을 프로미스로 돌려주고,spawnCostMs()는 빈 일감 하나로 기동 시간만 잽니다.place(job)의 규칙:cpuMs가 1 이하면"loop", 그보다 크고sharedState가 참이면"slice", 아니면cpuMs가 50 이상일 때"worker"이고 그 미만은"slice"입니다.cpuMs가 없거나 숫자가 아니면 예외를 던지세요.- 워커를 띄우는 프로그램은
node -e로 돌리지 마세요. 워커가 부모의 실행 인자를 물려받아ERR_INPUT_TYPE_NOT_ALLOWED로 죽습니다. 재는 프로그램도 파일로 두고node measure.mjs처럼 돌립니다.
경계에서 흘리지 않게 나눈다
/root/work/worker/split.mjs 에 chunks(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.json 에 node·total 과 runs.whole·runs.sliced 를 적으세요. 두 벌 모두 samples·p50·p99·max·workMs·result 를 담고, runs.sliced 에는 slices 도 담습니다.
재는 자는 2번 실습의 withLag 과 같은 모양으로 이 파일에도 하나 두면 됩니다.
result 가 두 벌에서 같아야 합니다. 다르다면 조각 사이에 seed 를 안 넘긴 것입니다 — 쪼개기는 답을 바꾸지 않습니다.
쪼개기의 값을 계산한다
slicing 에 costRatio(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.mjs 에 runInWorker(total) 을 export 하세요. hashRange(0, total) 과 같은 값을 프로미스로 돌려주고, 동시에 두 번 불러도 되어야 합니다.
워커는 메모리를 공유하지 않습니다. 필요한 값은 workerData 로 건네고 결과는 postMessage 로 돌려받습니다.
그래서 "지금 요청의 객체를 워커에서 고쳐 달라" 는 안 됩니다. 복사해서 보내고 복사해서 받을 뿐입니다 — 옮길 수 있는 일과 없는 일이 여기서 갈립니다.
워커의 값을 계산한다
워커가 도는 동안을 재어 runs.worker 에 samples·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 이라는 경계는 앞 단계에서 잰 기동 비용에서 나왔습니다. 다른 기계에서는 다른 값이 나오고, 그러면 이 상수도 바뀌어야 합니다 — 규칙은 측정에 딸린 것입니다.