Promise.all은 어떻게 병렬 처리를 할까

ggsno·2023년 9월 2일
1

JavaScript

목록 보기
1/1
post-thumbnail

Execute Asynchronous Functions in Parallel

비동기 함수들의 배열을 병렬적으로 실행한 결과를 배열로 반환하는 함수를 만드는 문제가 있었다. 문제의 조건으로 Promise.all을 사용하지 않아야 한다. leetcode 문제 링크

만들다보니 Promise.all의 병렬 처리에 대해 궁금점이 생겼다. 싱글스레드로 작동하는 자바스크립트 엔진이 어떻게 병렬처리를 할 수 있을까? Promise.all을 사용하면 병렬 처리가 가능하다는데, Promise의 콜백도 결국 콜스텍에서 실행되지 않나?

Promise의 콜백은 언제 실행되나

Promise의 콜백은 Promise 객체가 생성될 때 즉시 실행된다.

const promise = new Promise((resolve) => {
  console.log("in");
  resolve("done");
});

promise.then(console.log);

console.log("out");

// in, out, done 순으로 출력

Promise.all의 인자로 넘겨지는 Promise 객체들도 마찬가지로 Promise 객체 생성시 콜백을 즉시 실행한다.

const promises = [
  new Promise((res) => {
    console.log("in ps 1");
    res("done ps 1");
  }),
  new Promise((res) => {
    console.log("in ps 2");
    res("done ps 2");
  })
];

console.log("hi");
Promise.all(promises).then(console.log);
console.log("bye");

/**
 in ps 1
 in ps 2
 hi
 bye
 ["done ps 1", "done ps 2"]
*/

이 말은 곧 Promise.all 메소드는 배열로 받은 Promise들의 상태를 감시할 뿐, 실행에 관여하지 않는다고 볼 수 있다. 즉, Promise.all은 병렬처리에 관여하지 않는다.

다음은 MDN 문서에서 Promise의 동시성에 대한 설명 중 일부이다.

Note that JavaScript is single-threaded by nature, so at a given instant, only one task will be executing, although control can shift between different promises, making execution of the promises appear concurrent. Parallel execution in JavaScript can only be achieved through worker threads.
- MDN, Promise concurrency

왜 Promise.all이 병렬처리를 하는 것처럼 생각했을까

Promise.all의 동작을 보여줄 때 항상 나오는 게 있다. setTimeout이다.

console.time("ps");                    
Promise.all([
	new Promise((res) => {
  		setTimeout(() => res("done"), 5000);
	}),
	new Promise((res) => {
  		setTimeout(() => res("done"), 5000);
	}),
	new Promise((res) => {
  		setTimeout(() => res("done"), 5000);
	}),
	new Promise((res) => {
  		setTimeout(() => res("done"), 5000);
	}),
]).then(() => console.timeEnd("ps"));

// ps의 시간은 대략 5000ms 가 찍힌다.

위의 예시에서 찍히는 시간이 5000ms * 4가 아닌 5000ms 이기 때문에 마치 Promise.all이 병렬적으로 처리되는 것처럼 보인다. 하지만 이는 Promise.all과는 무관한 일이다.

각 Promise 콜백들은 WebAPI에서 각각 독립적으로 타이머가 흘러가고, 해당 시간이 되면 테스크큐로 넘어와 이벤트 루프가 콜스텍으로 넣어줘서 실행해줄 때까지 기다릴뿐이다.

결국 실행되는건 콜스텍에 들어가는 콜백인데, 위의 코드에서 실행되는 코드가 워낙 짧기 때문에 병렬적으로 실행되는 것처럼 보인다.

아래 코드는 무거운 작업을 Promise.all로 돌렸을 때의 결과이다.

const heavyWork = (key) => {
    console.time(key);
    for(let i = 0; i < 5000000000; i++) {};
    console.timeEnd(key);
};

console.time("all");
Promise.all([
    new Promise(res => {res(heavyWork("1"))}),
    new Promise(res => {res(heavyWork("2"))}),
    new Promise(res => {res(heavyWork("3"))}),
    new Promise(res => {res(heavyWork("4"))}),
]).then((res) => console.timeEnd("all"));

// 필자의 컴퓨터 기준으로 각 함수는 약 5000ms씩 걸리고 모두 처리하는 시간은 5000ms이 훨씬 넘는다.

img

위의 코드가 만약 병렬적으로 처리됐다면 "all"의 시간과 각 함수가 실행 시간이 거의 비슷해야 하지만 결과는 그렇지 않다.

정리하자면, Promise.all은 함수의 실행에 관여하지 않는다.

Promise.all을 구현해보자

아래의 코드는 맨 처음에 언급했던 leetcode 문제에 대한 필자의 답이다.

var promiseAll = async function(functions) {
    return new Promise((resolve, reject) => {
        const arr = [];
        let count = 0;
        functions.forEach((fn, i) => {
            fn().then((res) => {
                arr[i] = res;
                if (++count === functions.length) {
                    resolve(arr);
                };
            }).catch(reject);
        });
    });
};

forEach 메서드 내부에서 fn이 실행 될 때 Promise 객체가 생성되고, 각 Promise가 resolve 되었을 때 then 메서드를 통해 결과가 배열로 들어간다. 이 때 fn만 실행하고 then 메서드의 콜백은 테스크큐로 넘어가 나중에 실행된다. 결과의 개수가 처음 받았던 비동기함수 functions의 개수와 같아지면 결과들의 배열을 resolve로 전달한다.

그런데 forEach에도 헷갈리는 게 있었다. await를 사용한다면 어떻게 구현해야할까?

var promiseAll = async function(functions) {
    return new Promise((resolve, reject) => {
        const arr = [];
        let count = 0;
        functions.forEach(async (fn, i) => {
            try {
                arr[i] = await fn();
                if (++count === functions.length)
                    resolve(arr);
            } catch (err) {
                reject(err);
            }
        });
    })
};

코드를 보면 forEach를 돌면서 await로 기다리는 듯한 모습을 볼 수 있다. 하지만 실제로는 await 이후의 코드는 콜백으로 던져버리고 함수를 종료하기 때문에 then 코드와 같은 결과가 나온다.

다음은 forEach에 비동기 함수를 넣은 예제이다.

[1,2,3].forEach(async (e) => {
    console.log(e);
    await new Promise((resolve) => {
        setTimeout(()=>{
            resolve();
        }, 1000);
    });
    console.log(e + "after");
});
console.log("done");

// 1 2 3 done 1after 2after 3after 순으로 출력된다

참고자료

MDN, Promise
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#promise_concurrency

Stackoverflow, Promise.all는 실제로 병렬처리를 하나요? 질문
https://anotherdev.xyz/promise-all-runs-in-parallel/

개인블로그, Promise.all 병렬처리 자세한 설명
https://stackoverflow.com/questions/67696657/does-promise-all-run-the-promises-in-parallel

개인블로그, 동시성과 벙렬처리의 차이 자세한 설명
https://blog.openreplay.com/promises-in-parallel/

profile
깊고 넒은 삽질

0개의 댓글