한입 CS

프로세스와 스레드: 메인 스레드가 막히면

이벤트 루프, 태스크와 마이크로태스크, 긴 작업, 워커, GIL

3장 · 맨 아래 층부터 · 읽는 시간 약 20분

목차 · 진행 0%

    주문 내역 CSV를 올리면 브라우저에서 파싱해 표로 보여 주는 화면이 있다. 파일이 몇 MB를 넘자 업로드 버튼을 누른 뒤 몇 초 동안 화면이 굳는다. 취소 버튼도 눌리지 않고, 입력란에 글자도 쳐지지 않는다. 함수에 async를 붙이고 await file.text()로 읽었는데도 그렇다.

    async function onUpload(file: File) {
      const text = await file.text()
      const rows = parseCsv(text)
      renderTable(rows)
    }

    async는 "기다리는 동안 다른 일을 해도 된다"는 표시일 뿐, 계산을 다른 곳으로 옮겨 주지 않는다. parseCsv가 2초 동안 계산하면, 그 2초 동안 페이지의 JavaScript가 도는 스레드는 다른 아무것도 하지 못한다. 이 장은 그 스레드가 무엇이고, 일을 어떤 순서로 처리하며, 어떻게 덜 막히게 하는지 본다.

    프로세스와 스레드

    프로세스(process)는 실행 중인 프로그램 하나다. 운영체제는 프로세스마다 메모리 공간을 따로 준다. 한 프로세스가 다른 프로세스의 메모리를 바로 읽을 수 없고, 하나가 죽어도 다른 하나는 산다. 스레드(thread)는 한 프로세스 안의 실행 흐름이다. 같은 프로세스의 스레드들은 메모리를 함께 쓴다. 그래서 값을 주고받기는 쉽지만, 두 스레드가 같은 값을 동시에 고치면 꼬일 수 있다(다음 장의 주제다).

    브라우저는 여러 프로세스로 나뉘어 돈다. Chrome의 사이트 격리(Site Isolation)는 데스크톱에서는 서로 다른 웹사이트의 페이지를 항상 다른 프로세스에 넣는다(Android는 일부 사이트만)(Chromium: Site Isolation). 한 탭이 멈춰도 다른 사이트 탭이 같이 멈추지 않는 이유다. 그리고 페이지 하나의 JavaScript는 그 페이지를 맡은 프로세스의 메인 스레드(main thread) 하나에서 돈다. 클릭 처리, 스타일·레이아웃 계산도 같은 스레드의 일이다. 메인 스레드는 한 번에 작업 하나만 처리한다(web.dev: 긴 작업 최적화).

    여러 일을 번갈아 진행해 함께 나아가게 하는 것을 동시성(concurrency), 여러 코어에서 같은 순간에 실제로 함께 실행하는 것을 병렬성(parallelism)이라고 구분한다. async/await가 주는 것은 동시성이다. 기다리는 동안 다른 일로 넘어갈 수는 있어도, 계산 두 개를 같은 순간에 돌리지는 않는다.

    이벤트 루프: 태스크 하나, 마이크로태스크 전부

    메인 스레드가 일을 고르는 규칙이 이벤트 루프(event loop)다. 할 일은 두 줄로 선다. 스크립트 실행, 이벤트 처리, setTimeout 콜백은 태스크 큐(task queue)에 들어간다. Promise의 .then 콜백과 queueMicrotask로 넣은 함수는 마이크로태스크 큐(microtask queue)에 들어간다. 이벤트 루프는 태스크를 하나 실행하고, 그 태스크가 끝나면 다음 태스크로 가기 전에 마이크로태스크 큐를 빌 때까지 실행한다. 마이크로태스크가 새 마이크로태스크를 넣으면 그것도 이번에 실행한다(MDN: 마이크로태스크 가이드). 개념 정리는 이벤트 루프 노트에 있다.

    console.log('A')
    setTimeout(() => console.log('B'), 0)
    Promise.resolve().then(() => console.log('C'))
    console.log('D')
    예측먼저 답해 보세요

    네 줄짜리 스크립트(log A, setTimeout → B, then → C, log D)의 출력 순서는?

    선택지와 해설 보기
    실험 태스크 하나, 마이크로태스크 전부
    실험태스크 하나, 마이크로태스크 전부

    코드 아래 상자는 이벤트 루프가 보는 상태입니다. 콜백에는 코드 주석의 이름을 붙였습니다. 한 번 누를 때마다 사건 하나(꺼냄·출력·큐에 넣음)가 일어나고, ▶는 지금 실행 중인 줄입니다. 규칙은 태스크 하나를 실행하고, 마이크로태스크 큐를 빌 때까지 비운 뒤, 다음 태스크로 가는 것입니다.

    1console.log('A')2setTimeout(() => { // timeout3  console.log('B')4}, 0)5Promise.resolve().then(() => { // then6  console.log('C')7})8console.log('D')
    실행 중
    1. 없음
    태스크 큐
    1. script
    마이크로태스크
    1. 비었음
    출력
    1. 아직 없음

    1 / 11단계. 스크립트 전체를 실행하는 것이 첫 번째 태스크다.

    • 해볼 것
    • 'setTimeout과 then'에서 C가 B보다 먼저 찍히는 단계까지 넘긴다
    • '마이크로태스크가 넣은 일'에서 4가 5보다 먼저 찍히는 것을 본다

    'setTimeout과 then'과 '마이크로태스크가 넣은 일' 두 입력으로 큐가 움직이는 모습을 볼 수 있다. 뒤의 입력에서는 m1이 실행 중에 m2(4를 찍는다)를 넣는데, 그때 태스크 큐에는 t1(5를 찍는다)이 먼저 서 있다.

    네 줄짜리 스크립트는 A D C B를 찍는다. setTimeout의 0은 "0ms 뒤 실행"이 아니라 "태스크 큐 맨 뒤에 줄 서기"다. 마이크로태스크가 실행 중에 넣은 마이크로태스크도 먼저 줄 서 있던 태스크보다 먼저 실행된다. 마이크로태스크는 지금 태스크가 끝나는 즉시 끼어든다.

    이벤트 루프를 코드로 줄이면 반복문 두 개다.

    while (tasks.length > 0) {
      run(tasks.shift()) // 태스크 하나
      while (micros.length > 0) run(micros.shift()) // 마이크로태스크는 빌 때까지
    }

    실제 브라우저에는 이 반복문 두 개짜리 모델에 없는 단계가 더 있다. 태스크와 태스크 사이에 화면을 그리는 차례가 오고, 태스크 큐도 종류별로 여러 개다. 이 모델은 "태스크 하나 → 마이크로태스크 전부"라는 순서 규칙만 남겼다.

    긴 작업과 놓치는 프레임

    화면을 그리는 것도 메인 스레드의 일이다. 60Hz 화면이라면 1초에 60번, 약 16.7ms마다 새 프레임을 그릴 기회가 온다. 그런데 메인 스레드가 태스크 하나를 끝내지 않으면 그 사이 그리기 차례가 오지 않는다. web.dev는 50ms를 넘는 태스크를 긴 작업(long task)이라 부르고, 긴 작업 동안 브라우저는 사용자 입력에 반응하지 못한다고 설명한다(web.dev: 긴 작업 최적화).

    예측먼저 답해 보세요

    메인 스레드가 200ms짜리 작업을 한 번에 실행하면 프레임을 몇 개 놓칠까? (60Hz, 작업이 끝나면 지나간 마감 중 하나만 그리는 모델)

    선택지와 해설 보기
    실험 긴 작업은 프레임을 몇 개 놓칠까
    실험긴 작업은 프레임을 몇 개 놓칠까

    60Hz 화면이라 약 16.7ms마다 프레임 마감이 옵니다. 메인 스레드는 일하는 동안 그리지 못하고, 작업이 끝나 메인 스레드가 비면 지나간 마감 중 마지막 하나만 그립니다(그리기 2ms). 30ms에 사용자가 버튼을 누릅니다. 실제 브라우저를 잰 것이 아니라 원리만 남긴 모델입니다.

    작업
    방식
    작업 그리기 클릭(30ms)

    놓친 프레임 11개, 클릭 응답 지연 170ms, 작업 완료 200ms.

    • 해볼 것
    • 200ms 작업을 한 번에 돌려 놓친 프레임 수를 본다
    • 같은 작업을 10ms씩 쪼개 클릭 응답 지연이 10ms 아래로 내려가는 것을 본다
    • 워커로 보내 놓친 프레임이 0이 되는 것을 본다

    200ms를 한 번에 돌리면 마감 12번 중 11번을 놓치고, 작업 도중(30ms)에 누른 클릭은 작업이 끝나는 200ms에야 처리되어 170ms 늦게 반응한다. 10ms씩 쪼개 조각 사이에 메인 스레드를 비워 주면(양보, yield) 놓치는 프레임이 없고 클릭도 곧 처리된다. 대신 사이사이 그리기가 끼어 작업이 끝나는 시각은 224ms로 늦어진다(조각 사이 그리기 2ms × 12번 = 24ms가 끼어서). 워커(worker)로 보내면 메인 스레드는 그리기만 하고, 계산은 다른 스레드에서 200ms 만에 끝난다.

    브라우저에서 쪼개는 방법은 조각 사이에 setTimeout으로 다음 조각을 새 태스크로 넘기거나, 이런 용도로 만든 scheduler.yield()를 쓰는 것이다. setTimeout으로 넘긴 조각은 태스크 큐 맨 뒤에 서지만, scheduler.yield()로 넘긴 나머지는 같은 우선순위의 다른 대기 태스크보다 먼저 이어 실행된다(web.dev: 긴 작업 최적화). 다만 Safari는 아직 scheduler.yield()를 지원하지 않는다(MDN: Scheduler.yield()). "태스크 하나 → 마이크로태스크 전부" 규칙대로라면 await Promise.resolve()로는 양보가 되지 않는다. 마이크로태스크는 태스크가 끝나기 전에 이어서 실행되니 그리기 차례가 오지 않는다.

    메인 스레드 밖으로 보내기

    쪼개기는 계산 총량을 줄이지 않는다. 계산 자체가 크면 다른 스레드나 프로세스로 보낸다.

    • 브라우저: Web Worker. 워커는 백그라운드 스레드에서 스크립트를 돌려 화면을 막지 않는다. 대신 DOM에 손댈 수 없고, 메인 스레드와는 postMessage로만 주고받는다. 보낸 데이터는 공유되지 않고 구조화된 복제(structured clone)로 복사된다. 큰 ArrayBuffer는 복사 대신 소유권을 넘길(transfer) 수 있다(MDN: Web Worker 사용하기). 처음 장면의 CSV 파싱은 워커로 보내고 결과 배열만 받아 그리면 된다.
    • Node.js: libuv 스레드 풀(thread pool). Node도 JavaScript는 한 스레드에서 돈다. libuv(Node가 파일·네트워크 입출력에 쓰는 C 라이브러리) 스레드 풀이 맡는 일은 두 갈래다. 하나는 운영체제가 비동기로 해 주지 못하는 입출력으로, fs API(동기 버전과 파일 감시 제외)와 dns.lookup()이 그렇다. 다른 하나는 메인 스레드에서 돌리면 오래 막을 무거운 계산으로, crypto.pbkdf2()·crypto.scrypt() 같은 비동기 암호 API와 zlib 압축 API(동기 버전 제외)가 그렇다. 둘 다 이 풀에서 돌고, 풀의 기본 크기는 4다(Node.js 문서: UV_THREADPOOL_SIZE). 반대로 …Sync 함수는 JavaScript 스레드에서 끝날 때까지 돈다. 그동안 그 서버는 다른 요청을 하나도 처리하지 못한다. 블로킹과 동기의 차이는 동기·비동기와 블로킹 노트에 있다.
    • Python: GIL과 multiprocessing. CPython에는 한 번에 한 스레드만 Python 바이트코드(bytecode, 소스를 번역해 둔 중간 명령. 9장)를 실행하게 하는 전역 인터프리터 락(global interpreter lock, GIL)이 있다. 입출력(I/O)을 기다릴 때는 GIL을 놓으므로 네트워크 요청 여러 개를 동시에 기다리는 일에는 스레드가 쓸모 있다. CPU만 쓰는 계산(CPU-bound)은 스레드를 늘려도 동시에 돌지 않는다(Python 용어집: GIL). 이럴 때 multiprocessing은 스레드 대신 프로세스를 써서 GIL을 피한다(Python 문서: multiprocessing). Python 3.13부터는 GIL을 끈 빌드(free-threaded build)를 따로 만들 수 있다. 기본 빌드에는 여전히 GIL이 있다.
    함정 찾기 AI가 짠 내보내기 API, 요청이 몰리면 서버 전체가 왜 굳을까?
    함정 찾기AI가 짠 내보내기 API, 요청이 몰리면 서버 전체가 왜 굳을까?
    내가 AI에게 한 요청
    “비밀번호를 다시 확인한 뒤 주문 보고서 HTML을 템플릿으로 만들고, gzip으로 압축해 내려 주는 Express 핸들러를 짜 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. web.dev가 긴 작업(long task)이라고 부르는 기준은?

    2. setTimeout(f, 0)을 등록한 뒤 Promise.resolve().then(g)를 등록했다. 먼저 불리는 것은?

    3. CPython 기본 빌드에서 CPU만 쓰는 계산을 코어 4개에 나눠 빨리 끝내려면?

    더 풀어 보기(선택) · setTimeout, then, queueMicrotask가 섞인 출력 순서
    보너스 문제setTimeout, then, queueMicrotask가 섞인 출력 순서

    아래 코드가 브라우저에서 찍는 순서를 맞혀 보자. 답을 적은 뒤 풀이를 열면 한 단계씩 넘겨 볼 수 있다.

    console.log('A')
    setTimeout(() => {
      console.log('B')
      queueMicrotask(() => console.log('C'))
    }, 0)
    Promise.resolve().then(() => {
      console.log('D')
      setTimeout(() => console.log('E'), 0)
      queueMicrotask(() => console.log('F'))
    })
    queueMicrotask(() => console.log('G'))
    console.log('H')

    이 장을 언급한 노트

    다음4장. 동시성: 검색 결과가 뒤죽박죽 나오는 이유스레드 다음은 동시성입니다. 자동완성 결과가 왜 뒤죽박죽 나오는지 봅니다.

    『한입 CS』 3장. 프로세스와 스레드: 메인 스레드가 막히면 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션