[번역] 동시성, 병렬성, 그리고 자바스크립트에 대한 이해

Sonny·2024년 10월 28일
40

Article

목록 보기
26/27
post-thumbnail

원문 : https://www.rugu.dev/en/blog/concurrency-and-parallelism/

지금까지는 동시성과 병렬성을 같은 의미로 사용하는 경우가 많아서 실제로는 다른 개념이라는 것을 알지 못했습니다. "용감하고 진실한 사람을 위한 클로저"라는 책의 9장을 읽으면서 두 개념이 다르다는 것을 알게 되었습니다.

이를 계기로 동시성 및 병렬성과 관련된 개념을 더 배우고 싶어졌습니다. 특히 제가 가장 잘 아는 프로그래밍 언어인 자바스크립트와 관련된 내용을 탐구하고자 했습니다. 따라서 이 글은 기본적으로 이 학습 과정에서 제가 기록한 내용을 정리한 것입니다.

순차, 동시 및 병렬 처리

우리는 일상에서 작업을 수행할 때 순차적으로, 동시에 또는 병렬로 실행합니다. 이는 컴퓨팅에도 적용됩니다.

순차적인 실행은 기본적으로 작업이 겹치지 않고 차례대로 수행되는 것을 말합니다. 예를 들어, 누군가가 먼저 휴대폰을 보고 작업을 완료한 후 수프를 먹기 위해 다른 작업으로 전환하는 경우, 이를 순차적으로 작업한다고 합니다. 이 접근 방식의 문제점은, 예를 들어 휴대폰으로 친구에게 무언가를 물어볼 때 친구가 대답이 돌아올 때까지 다른 작업으로 전환하지 않으면 시간을 낭비하게 된다는 것입니다. 따라서 때때로 다양한 형태의 멀티태스킹이 시간을 절약하는 데 도움이 될 수 있습니다. 동시성과 병렬성은 멀티태스킹을 달성하는 방법입니다. 하지만 이 둘 사이에는 미묘하지만 중요한 차이점이 있습니다.

동시성은 하위 작업을 번갈아 가며 수많은 작업을 처리하는 것(일명 인터리빙)이고, 병렬성은 여러 작업을 동시에 수행하는 것과 같습니다. 예를 들어, 휴대전화를 보다가 국물을 한 숟가락 떠먹기 위해 내려놓았다가 숟가락을 내려놓은 후 다시 휴대전화를 보는 경우, 이는 동시 작업입니다. 반대로 한 손으로 밥을 먹으면서 다른 한 손으로 문자를 보낸다면 병렬 작업을 하는 것입니다. 두 경우 모두 멀티태스킹이지만, 멀티태스킹을 처리하는 방식에는 미묘한 차이가 있습니다.

parallel-interleaved.png
(동기 vs 비동기 vs 동시 vs 병렬에서 가져온 이미지)

스레드

위의 비유에서 수프를 먹는 것과 한 손으로 전화기를 사용하는 것을 서로 다른 작업으로 구분했고, 각 작업은 또다른 하위 작업으로 구성될 수 있다고 했습니다. 예를들어, 수프를 먹으려면 숟가락을 들고 수프에 넣은 다음, 입에 넣어야 합니다.

프로그래밍 맥락에서 다시 말해보자면, 프로세스의 더 큰 명령어 집합에서, 하위 작업은 그 작업의 개별적인 부분으로 생각할 수 있습니다. 서로 다른 하위 작업을 동시에 처리하는 일반적인 방법은 서로 다른 커널 스레드를 생성하는 것입니다. 이는 마치 각각 특정 작업을 처리하는 각각의 작업자가 동일한 명령어 집합과 리소스를 사용하면서 작업하는 것과 같습니다.

스레드가 병렬로 실행되는지 또는 동시에 실행되는지는 하드웨어의 상황에 따라 다릅니다. CPU의 코어 수가 동시에 실행되는 스레드 수보다 많다면, 각 스레드를 다른 코어에 할당해 병렬로 실행할 수 있습니다. 그러나 CPU의 코어 수가 스레드 수보다 적은 경우 운영 체제는 스레드 간에 인터리빙을 시작합니다.

커널 스레드를 사용하는 경우, 개발자의 경험은 동일하게 유지되며 작업이 실제로 동시에 처리되든 병렬로 처리되든 큰 차이가 없습니다. 개발자는 성능을 개선하고 차단을 피하기 위해 스레드를 사용합니다. 그러나 사용 가능한 리소스에 따라 이러한 스레드를 처리하는 방법에 대한 최종 결정을 내리는 것은 운영 체제입니다. 개발자가 스레드를 사용하는 한, 동시 실행이든 병렬 실행이든 상관없으며 두 경우 모두 서로 다른 스레드의 명령이 실행되는 순서는 예측할 수 없습니다. 따라서 개발자는 동일한 데이터에서 작동하는 두 개의 서로 다른 스레드에서 발생할 수 있는 잠재적인 문제(예: 경쟁 상태, 데드락, 라이브락 등)에 주의해야 합니다!

프로세스 생성, I/O 알림

스레드를 사용하는 것 외에도 동시성/병렬성을 달성할 수 있는 다른 방법이 있습니다. 예를 들어, 스레드만큼 효율적이지는 않지만, 여러 프로세스를 생성하는 것도 하나의 방법입니다. CPU는 여러 프로세스를 병렬 및 동시에 실행하기 때문에 여러 프로세스를 사용하여 멀티태스킹할 수 있습니다. 단점은 각 프로세스에 고유한 메모리 공간이 할당되어 있고 스레드처럼 기본적으로 메모리 공간을 공유하지 않는다는 것입니다. 따라서 서로 다른 프로세스가 동일한 상태에서 작동해야 하는 경우 공유 메모리 세그먼트, 파이프, 메시지 큐 또는 데이터베이스와 같은 일종의 IPC 메커니즘이 필요할 수 있습니다.

커널은 또한 자체적인 방식의 I/O 이벤트 알림 메커니즘을 구현하는데, 이 역시 특정 작업을 수행하는 동안 차단되지 않기를 원하는 프로그램을 빌드할 때 유용하게 사용할 수 있습니다.

제가 잘 모르기 때문에 자세한 내용은 다루고 싶지 않지만, 핵심은 커널 스레드가 동시성을 달성할 수 있는 유일한 OS 전용 방법은 아니라는 점입니다.

Node.js, 유저 스페이스에서의 동시성 예시

프로그래밍 언어는 운영 체제의 API(시스템 호출) 사용과 관련된 복잡성을 단순화하기 위해 자체적인 동시성 메커니즘을 제공하는 경우가 많습니다. 즉, 컴파일러나 인터프리터가 고수준 코드를 운영 체제가 이해할 수 있는 저수준 시스템 호출로 변환하여 사용자가 많은 생각을 할 필요가 없도록 해줍니다.

Node.js가 이 개념의 좋은 예입니다. 자바스크립트 프로그램은 순차적 실행 흐름의 단일 스레드 환경에서 실행되지만 IO 작업과 같은 블로킹 작업은 Node.js 워커 스레드에 위임됩니다. 따라서 Node.js는 개발자에게 이러한 차단 작업을 관리하는 복잡성을 드러내지 않고 백그라운드에서 스레드를 사용하여 이러한 차단 작업을 관리합니다.

작동 방식은 다음과 같습니다. 파일의 읽기 또는 쓰기, 네트워크 요청 전송과 같은 차단 작업은 일반적으로 Node.js에서 제공하는 내장 함수를 사용하여 처리됩니다. 일반적으로 이러한 함수를 호출할 때 콜백 함수를 매개변수로 전달하면 Node.js 워커 스레드가 작업을 완료할 때 제공한 콜백 함수를 실행할 수 있도록 합니다.

Node.js architecture
(Node.js 아키텍처에서 가져온 이미지)

Node.js 동시성이 내부에서 어떻게 작동하는지 조금 더 이해했으니 이제 특정 사례/상황을 살펴봄으로써 이 이론을 연습해볼 수 있습니다.

다음 코드를 살펴보세요 (예제를 제시해 준 제 친구 Onur에게 감사드립니다).

setTimeout(() => {
    while (true) {
        console.log("a");
    }
}, 1000);

setTimeout(() => {
    while (true) {
        console.log("b");
    }
}, 1000);

여기서 이 프로그램을 실행하면 화면에 표시되는 것은 "a"뿐입니다. 이는 Node.js 인터프리터가 아직 사용 가능한 명령어가 있는 한 현재 콜백을 계속 실행하기 때문입니다.

메인 코드의 모든 명령어가 실행되는 즉시 Node.js 런타임 환경은 콜백 함수를 호출하기 시작합니다. 작성하는 메인 코드가 기본적으로 콜백으로 호출된다고 생각할 수도 있습니다. 위 예시에서 첫 번째 setTimeout은 제공된 콜백 함수와 함께 실행되고, 두 번째 setTimeout도 콜백 함수와 함께 실행됩니다. 1초가 지나면 "a"를 스팸으로 보내기 시작합니다. 첫 번째 콜백이 호출되면 그 콜백이 메인 스레드를 차지하고, 추악한 while 루프가 끝없이 실행되기 때문에 "b"는 절대 보이지 않습니다! 따라서 두 번째 콜백은 절대 호출되지 않습니다.

여기에는 몇 가지 중요한 효과가 있습니다. 첫째, 특히 C 언어와 같은 멀티스레드 언어에 비해 경쟁 상태와 같은 문제가 발생할 가능성이 줄어듭니다. 왜 그럴까요? C 언어와 유사한 언어에서는 CPU가 명령어 수준에서 스레드를 교차 실행하지만, 여기서는 대부분 콜백 수준에서 이루어집니다. 중첩 콜백이 있는 async 함수에 의존하는 복잡한 로직만 피한다면 기본적으로 실행 흐름이 중단되지 않고 순차적으로 유지된다는 것은 확실합니다.

프로그래밍 로직에 많은 비동기 콜백 기반 함수(예를 들어 fs.readFile(), setTimeout(), setImmediate() 같은 함수, 또한 Promise.then()도)가 포함되어 있으면 경쟁 상태가 쉽게 발생할 수 있습니다.

await에서도 경쟁 상태가 발생할 수 있습니다. 왜냐하면 await 구문은 현재 스코프를 기준으로 남은 코드를 Promise의 then 메서드 안의 콜백 함수로 랩핑한 것을 간단히 표현한 방식으로 볼 수 있기 때문입니다.

아래의 testtest2 함수를 살펴보겠습니다.

const {scheduler} = require('node:timers/promises'),

    test = async () => {
        let x = 0

        const foo = async () => {
            let y = x
            await scheduler.wait(100)
            x = y + 1
        }

        await Promise.all([foo(), foo(), foo()])
        console.log(x) // Returns 1, not 3
    },

    test2 = async () => {
        let x = 0

        const foo = async () => {
            await scheduler.wait(100)
            let y = x
            x = y + 1
        }

        await Promise.all([foo(), foo(), foo()])
        console.log(x) // Returns 3
    },

    main = () => {
        test()
        test2()
    }

main()

test()가 1을 기록하는 이유는 foo 함수들이 호출될 때 await scheduler.wait(100)를 만나자마자 기본적으로 완료되기 때문입니다. 왜냐하면 내부적으로 await scheduler.wait(100)을 사용하면 다음과 같이 평가되기 때문입니다.

scheduler.wait(100).then(() => {
    x = y + 1
})

따라서 첫 번째 foo 함수가 작업을 완료하면 이제 콜백 함수가 후속 작업을 이어가야 하지만, 그 콜백 함수는 100ms 후에 호출되므로, Node.js 인터프리터는 유휴 상태로 있지 않고 두 번째 및 세 번째 foo 함수를 순서대로 계속 실행합니다. 또한 첫 번째 foo의 콜백이 트리거되기 전에 y 변수를 x 값으로 설정하고 콜백 함수로 scheduler.wait를 호출합니다. 결과적으로 콜백이 최종적으로 실행될 때 모두 이전 값인 x를 사용하여 x를 업데이트하므로 3이 아닌 1을 얻게 됩니다.

test2()를 실행할 때 3이 출력되는 이유는 무엇인가요? await이 실행되는 위치가 다르고 다음과 같이 평가되기 때문입니다.

scheduler.wait(100).then(() => {
    let y = x
    x = y + 1
})

이 콜백 함수가 호출되면, 그 사이에 아무 것도 끼어들 수 없습니다.

let y = x
x = y + 1

따라서 경쟁 상태가 발생할 수 없습니다.

결론

여기서 중요한 점은 "동시성"을 달성하는 방법은 한 가지가 아니며 달성 방법에 따라 프로그램의 성능이나 발생할 수 있는 문제, 주의해야 할 사항 등 여러 가지에 영향을 미칠 수 있다는 것입니다.

동시/병렬로 작동해야 하는 프로그램에서 작업할 때는 주의하세요. 일이 순식간에 잘못될 수 있습니다.

profile
FrontEnd Developer

7개의 댓글

comment-user-thumbnail
2024년 10월 29일

정말 설명 직관적이고 좋네요~ 좋은 글 공유 감사합니다.

답글 달기
comment-user-thumbnail
2024년 11월 4일

hihi

답글 달기
comment-user-thumbnail
2024년 11월 5일

😍😍😍😍

답글 달기
comment-user-thumbnail
2일 전

안녕하세요! 여러 유용한 번역 글 잘 읽었습니다. 감사합니다.
혹시 이런 번역 글을 작성하실때, 원 저작자한테 허락을 맡아야하나요?
저도 좋은 아티클을 번역을 해볼까하는데, 어떻게 시작하셨는지 궁금합니다.

1개의 답글
comment-user-thumbnail
약 20시간 전

await가 then을 간단하게 나타난것으로 볼수있다는것은 오피셜인걸까요?? 신기하네요!

답글 달기