이벤트 루프 한 턴 — 마이크로태스크가 setTimeout(0) 보다 항상 먼저다
마이크로태스크 큐는 매 턴 끝까지 비워진다 — 그래서 then·queueMicrotask는 항상 setTimeout(0)보다 먼저다.1
한 스텝씩 보려면:
Footnotes
-
In depth: Microtasks and the JavaScript runtime environment — MDN: “execution of microtasks continues until the queue is empty—even if new ones are scheduled in the interim. In other words, microtasks can enqueue new microtasks and those new microtasks will execute before the next task begins to run” ↩
location.href 는 암묵적 await 가 아니다 — setter 는 동기, navigation 은 태스크
location.href에 값을 넣으면 그 줄에서 멈추고 이동할 것 같다 — 암묵적 await 처럼. 그럼 뒤의 코드는 안 돌까. 추측하지 말고 CDP로 깨 본다.
1. 실험을 건다
외부 도메인 대신 로컬 서버의 /a·/b로 이동시킨다(서버는 받은 요청을 기록한다). Runtime·Page·Network를 enable 하고, 마지막 식의 값까지 돌려받게 한 줄을 붙여 Runtime.evaluate로 실행한다.
location.href = '/a'
console.log('실행됨 1')
location.href = '/b'
console.log('실행됨 2')
// 'evaluate 끝까지 돌았다'
await였다면 첫 줄에서 멈춰 실행됨 1도 안 찍히고 /a로 가야 한다.
2. 이벤트 순서를 읽는다
CDP가 받은 순서 그대로다.1
| ms | 이벤트 | |
|---|---|---|
| 514 | Page.frameRequestedNavigation |
/a · scriptInitiated |
| 516 | Runtime.consoleAPICalled |
실행됨 1 |
| 517 | Page.frameRequestedNavigation |
/b |
| 517 | Runtime.consoleAPICalled |
실행됨 2 |
| 517 | Runtime.evaluate 응답 |
'evaluate 끝까지 돌았다' |
| 518 | Page.frameStartedNavigating |
/a |
| 520 | Network.requestWillBeSent |
/a |
| 521 | Network.loadingFailed |
net::ERR_ABORTED · canceled: true |
| 523 | Page.frameStartedNavigating · requestWillBeSent |
/b |
| 528 | Page.frameNavigated |
/b |
- setter는 동기로 끝나고 흐름을 안 끊는다. 이동 요청(
frameRequestedNavigation)이 console 로그 사이사이에 찍히고,evaluate가 마지막 식의 값을 돌려준다 — 코드는 끝까지 돌았다. - 실제 이동은 그 뒤에 시작된다.
frameStartedNavigating은evaluate가 끝난 다음이다. 문서 언로드·페치는 태스크로 넘어가 지금 코드가 끝난 뒤에 돈다. - 마지막 값이 이기는 건 앞의 것이 취소되기 때문이다.
/a가 시작됐다가/b가 시작되며ERR_ABORTED로 끊긴다.2 덮어쓴 게 아니다.
3. 덤 — 버려진 이동도 서버에는 갔다
서버 기록은 ["/a","/b"]다. /a는 requestWillBeSent까지 갔다가 클라이언트에서 끊겼을 뿐, 요청은 서버에 도착했다. 이동 전에 서버 부수효과가 있는 URL(로그아웃 링크 같은)이라면 “결국 /b로 갔으니 /a는 없던 일”이 아니다 — 572의 “취소는 클라이언트에서만 일어난다”와 같은 이야기다.
Footnotes
Node 에서 재귀 크롤을 이벤트 루프에 맡기기
종료 감지를 큐의 onIdle() 에1, 중복 제거의 원자성을 단일 스레드에2 맡기면 in-flight 카운터와 락이 사라진다.
Node 로 링크를 따라가며 페이지를 재귀 크롤할 때 꺼낸다. 처리 결과(새 링크)가 다시 입력으로 돌아가서 일이 언제 바닥날지 미리 모르는 경우다.
import PQueue from 'p-queue'
const queue = new PQueue({ concurrency: 10 }) // 동시 요청 수만 제한한다. add 는 막히지 않는다(무제한 대기열)
const seen = new Set()
function enqueue(url) {
if (seen.has(url)) return
seen.add(url) // has 와 add 사이에 await 가 없으니 다른 코드가 끼어들 수 없다 — 락이 필요 없다
queue.add(() => visit(url)) // 요청보다 먼저 표시한다. 요청 뒤에 표시하면 그 사이 같은 URL 이 또 들어온다
}
async function visit(url) {
const res = await fetch(url) // HEAD 가 아니라 GET — 링크를 더 찾으려면 본문이 있어야 한다
const html = await res.text()
for (const link of extractLinks(html, url)) enqueue(link) // 이 태스크가 끝나기 전에 자식을 넣는다
}
enqueue(startUrl)
await queue.onIdle() // 대기열이 비고 실행 중인 태스크도 0 일 때 resolve
extractLinks 는 HTML 에서 같은 사이트 링크를 뽑는 함수(자리표시자). 서로 가리키는 페이지가 있어도 onIdle() 이 resolve 되고 seen.size 가 고유 URL 수와 같으면 된 것이다.
- 출처: How Other Link Checkers Do Recursion — linkinator(
onIdle·GET)·broken-link-checker 분석
Footnotes
-
p-queue README: “Returns a promise that resolves when the queue is empty and all tasks have completed” —
queue.size === 0 && queue.pending === 0. ↩ -
JavaScript execution model — MDN: “whenever a function runs, it cannot be preempted and will run entirely before any other code runs” ↩