본문 바로가기

Python/Python

[Python] 동시성(concurrency)과 병렬성(parallelism) 이해하기

반응형

이번 포스팅에서는 여러 개의 작업을 한꺼번에 같이 수행할 수 있는 동시성(concurrency)과 병렬성(parallelism)에 대한 개념에 대해 이해해보자. 

 

출처: https://kwahome.medium.com/concurrency-is-not-parallelism-a5451d1cde8d


먼저 동시성에 대해 이해하기에 앞서서 동기, 비동기에 대한 개념부터 이해하고 넘어가는 것이 좋다. 동기, 비동기에 대한 개념적인 차이점이 무엇이고, 이를 파이썬으로 어떻게 구현할 수 있는지에 대해서도 알아보자. 그리고 난 뒤, 동시성과 병렬성에 대해 알아보도록 하자.

1. 동기(synchronous) vs 비동기(asynchronous)

동기 실행이라는 것부터 알아보자. 동기의 또다른 이름은 '순차적인 실행'이라고 정의할 수 있다. 예를 들어, 텍스트 데이터를 활용한 머신러닝 모델링을 한다고 해보자. 흐름은 다음과 같다. [1.텍스트 데이터 추출 → 2.텍스트 데이터를 숫자로 전처리 → 3.모델에 입력으로 넣기 → 4.모델 성능 측정] 이렇게 크게 4가지 단계로 구성된다. 이 4가지 단계는 반드시 이전 단계가 끝나야만 다음 단계가 실행하도록 하는 순차적인 실행을 지켜야 한다. 다시 말해, 2번 단계(데이터 전처리)가 끝나지 않았는데, 3번 단계(모델에 입력)를 시작할 수 없다는 것이다. 바로 이렇게 반드시 순차적인 실행 흐름을 지켜야 하는 것들 동기(synchronous) 실행이라고 부른다. 

 

그러면 비동기 실행은 무엇일까? 앞서 배웠던 동기 실행이라는 개념에 기반해서 생각해보면 약간 추론이 가능할 수도 있겠다. 바로 순차적으로 실행하지 않아도 되는 것. 이 말은 곧 이전 단계가 끝나지 않았더라도 다음 단계를 실행할 수 있다는 것이다. 간단히 그림으로 표시하면 아래와 같다.

 

출처 : https://adrianmejia.com/asynchronous-vs-synchronous-handling-concurrency-in-javascript/

 

물론 위 그림 속 비동기(Asynchronous) 부분을 보면 마치 1,2,3,4번 태스크를 한꺼번에 같이 작업하는 것으로 보일 수 있겠지만 엄밀히 말하면 1,2,3,4번 태스크를 스위칭 하면서 진행하는 것이다. 이는 동시성과 병렬성의 차이점에 대한 주제인데, 해당 부분은 아래 내용에서 자세히 알아보도록 하자. 여기서 알아둘 점은 비동기 실행은 병렬성이 아닌 동시성에 해당한다는 점이다.

 

그러면 이제 동기, 비동기 실행을 Python으로 작성했을 때, 어떤 차이점이 있는지 살펴보자. 코드 예시는 여러 웹사이트를 크롤링한다고 가정해보겠다. 먼저 동기 실행 코드이다.

 

import requests
import time
import os
import threading


def fetch(url):
    print(f"PID: {os.getpid()} | Thread: {threading.get_ident()} | url: {url}")
    response = requests.get(url)
    return response.text


def main():
    urls = ["https://www.naver.com", "https://www.google.com", "https://daum.net"] * 10
    for url in urls:
        fetch(url)


if __name__ == "__main__":
    start = time.time()
    main()
    print("elapsed-time:", time.time() - start) # 8.26 seconds

 

코드 출력 시에 프로세스와 쓰레드를 각각 몇 개 사용하는지 체크해보기 위해 출력해보았다. 당연히 별다른 설정을 해주지 않았기 때문에 1개의 프로세스, 1개의 쓰레드를 사용한다.

 

다음으로는 비동기 실행 코드를 살펴보자.

 

import aiohttp
import asyncio
import time
import os 
import threading


async def fetch(session, url):
    print(f"PID: {os.getpid()} | Thread: {threading.get_ident()} | url: {url}")
    async with session.get(url) as response:
        return await response.text()
    

async def main():
    urls = ["https://www.naver.com", "https://www.google.com", "https://daum.net"] * 10

    async with aiohttp.ClientSession() as session:
        _ = await asyncio.gather(*[fetch(session, url) for url in urls])


if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    print("elapsed-time:", time.time() - start) # 0.53 seconds

 

파이썬에서 비동기 실행을 하기 위해서는 코루틴을 활용하는 asyncio 라는 built-in 라이브러리를 사용해야 한다. 코루틴, asyncio에 대한 구체적인 사용법은 별도의 포스팅으로 다루려고 하니 여기에서는 위 코드처럼 작성하는구나 하고 넘어가면 좋겠다. 위 코드에서 주목할 부분은 URL로 request 요청을 통신할 때 동기 실행에서 사용한 requests 라이브러리가 아닌 aiohttp 외부 라이브러리를 사용했다는 점이다. 이렇게 한 이유는 requests 라이브러리는 비동기 함수(코루틴)가 아닌 일반적인 함수인 동기 함수로 작성된 것이기 때문이다. 즉, 비동기 실행을 하기 위해서는 사용하는 함수도 모두 코루틴 함수여야 하고 코루틴 함수로 작성된 aiohttp 라이브러리를 사용한 것이다. 그래서 만약 위 코드에서 aiohttp 가 아닌 일반적인 requests 라이브러리의 함수를 사용하면 엄밀히 말해서 비동기 프로그래밍이 아닌 셈이다.

 

그리고 두 코드 블럭의 마지막 줄을 보면 각 코드를 실행하는 데 걸린 시간이 몇 초가 걸렸는지를 적어보았다. 동기 코드에서는 8.26초가 비동기 코드에서는 0.53초가 걸렸다. 엄청난 큰 성능을 이끌어 낸 것이다. 

 

방금처럼 외부 URL에 요청을 보내는 이런 작업을 일종의 I/O 작업이라고 하며 어떤 프로그램에 이런 I/O 작업들이 대부분이라면 이를 I/O Bound 라고도 한다. 파이썬에서 비동기 실행은 이렇게 I/O Bound 작업에서 큰 성능을 발휘한다. 그래서 보통 파이썬에서의 비동기 프로그래밍은 대부분 I/O Bound 작업에 주로 활용된다.

 

또 다른 Bound 종류로는 CPU Bound가 있다. 쉽게 말해 CPU가 복잡하고 시간이 오래 걸리는 연산 작업을 하느라 매우 바쁘다는 뜻이다. CPU Bound는 주로 병렬성을 활용해서 해결하는데, 아래 병렬성이라는 개념에 대해서 배울 때 또 등장하니 기억해두도록 하자.

2. 동시성(Concurrency) vs 병렬성(Parallelism)

(참고로 동시성은 병행성이라고도 부른다. 헷갈리지 않게 앞으로는 병행성이라는 용어는 지양하고 동시성이라는 용어만 사용하겠다)

 

동시성과 병렬성의 차이점은 "at the same time" 이냐 아니냐의 차이이다. "at the same time"은 한국어로 하면 또 "동시에" 라는 말인데 이게 뭔 말장난인지 싶다. 헷갈리니 앞으로 동시성과 병렬성을 설명할 때는 "동시에" 라는 말은 사용하지 않고 "at the same time"이라고 바꾸어 언급하겠다. 여기서 "at the same time" 이란 "한 순간에 여러 개의 작업이 수행되느냐" 이다. 아래 그림을 보자.

 

동시성과 병렬성의 차이점

 

위 그림 속 Start 지점을 보자. Start 지점을 '한 순간의 지점' 이라고 할 수 있다. Start 지점에서 동시성은 task1만 실행되었고, 병렬성은 task1,2,3이 모두 실행되었다. 이럴 경우, 동시성은 "not at the same time" 이고, 병렬성은 "at the same time" 이라는 것이다. 

2-1. not at the same time ! : 동시성(병행성, Concurrency)

[목차 1번]에서 배웠던 비동기 실행이 바로 동시성에 해당한다고 했다. 동시성은 여러 개의 작업을 동시에 다루긴 하지만 이 '동시에 다루다' 라는 것이 여러 작업들 간에 스위칭 하면서 동시에 다룬다는 것을 의미한다.

 

동시성의 핵심은 "not at the same time" 과 "스위칭"

 

그리고 동시성은 논리적인(Logical) 개념이다. 여기서 '논리적이다'라고 하는 것은 물리적인 것에 구애 받지 않고 소프트웨어로 구현할 수 있다는 것이다. 즉, CPU Core가 몇 개이건, 쓰레드가 몇 개이던 심지어 CPU Core가 1개, 쓰레드가 1개여도 동시성 구현이 가능하다. 여기서 '물리적인 것에 구애받지 않는다' 라는 것은 아래 병렬성 내용을 이해해보면 무슨 의미인지 좀 더 와닿을 것이다.

 

동시성에 대한 코드 구현은 [목차 1번]에서 구현했던 비동기 실행 코드와 동일하기 때문에 별도로 해당 목차에서 또 다루지는 않겠다.

2-2. at time same time ! : 병렬성(Parallelism)

다시 한번 말하지만 병렬성은 한 순간에 여러 개의 작업을 같이 실행할 수 있다는 것이다. 아래 그림을 다시 보자.

 

병렬성의 핵심은 "at the same time"

 

병렬성은 동시성과 달리 물리적인(Physical) 개념이다. 즉, 여러 개 장착되어 있는 물리적인 장치를 단위로 해서 병렬적으로 실행하는 것이다. 이 물리적인 장치의 예로는 CPU Core 개수가 될 수도 있고, 쓰레드 개수가 될 수 있다.

 

 

그래서 병렬성을 구현하기 위해서는 무조건 멀티 코어 또는 멀티 쓰레드라는 물리적인 장치가 전제되어야 한다. 그래서 병렬성을 물리적인(Physical) 개념이라고 하며 자연스레 물리적인 장치에 구애받는다라고 할 수 있다. 따라서 병렬성을 구현하기 위해서는 멀티 프로세싱과 멀티 쓰레딩이 활용된다.

2-3. Python에서는 멀티 쓰레딩으로 병럴 처리가 불가능!

하지만 Python에서는 다른 언어들과 달리 멀티 쓰레딩을 활용한 병렬성 구현이 불가능하다. 왜냐하면 Python에서의 특유한 특성인 GIL(Global Interpreter Lock) 때문이다. 이 GIL이 무엇이고, 왜 이것 때문에 멀티 쓰레딩을 통한 병렬성이 구현이 안되는지 살펴보자.

 

우선 멀티 쓰레딩이라는 것은 쓰레드가 2개 이상을 가진다는 것을 의미한다. 그런데 이전에서 배웠다시피 여러 개의 쓰레드는 하나의 독립된 프로세스 내에 존재하는데, 각 쓰레드마다 독립적인 영역을 갖고 있기도 하지만 쓰레드들 간에 서로 공유하는 영역도 존재한다.

 

1개의 프로세스 내에 3개의 쓰레드가 있다고 가정

 

위 그림처럼 정적 영역이라는 부분이 3개의 쓰레드들 간에 공유하는 자원들이다. 그런데 이렇게 여러 개의 쓰레드들 간에 자원을 공유하면서 병렬 처리를 구현하다 보면 충돌이 발생할 수 있는데, 이런 충돌을 막고자 Python에서는 설계할 때 GIL이라는 것들 도입했다. GIL은 한 순간 즉, 어떤 특정 연산을 수행하기 위해서 1개의 쓰레드만 유지하도록 강제하는 일종의 락(lock)을 거는 것이다. 그래서 한 순간에 여러 개의 쓰레드를 유지하지 못하게 되므로 여러 개의 쓰레드가 있음에도 불구하고 Python에서는 멀티 쓰레딩을 통한 병렬 처리가 불가능하다는 것이다. 

 

그래서 CPU Bound 작업인 경우 병렬성을 통해 처리하는데, Python에서는 위와 같은 이유 때문에 멀티 쓰레딩을 활용해도 병렬 처리를 하지 못한다. 여기서 왜 CPU Bound와 쓰레드가 서로 연관이 있지? 라는 의문이 든다면 이전 포스팅의 [TCB란 무엇일까?] 목차를 참조해보자. 간단히 말해서 연관이 있는 이유는 쓰레드는 프로그램의 연산을 실행하는 단위이고, 결국 CPU 연산이 실질적으로 실행되는 곳이 쓰레드이기 때문이다.

 

이러한 문제 때문에 Python에서는 멀티 쓰레딩을 활용해서는 병렬 처리가 되지 않기 때문에 [멀티 쓰레딩 vs 싱글 쓰레드로 비동기 실행] 선택지에 대한 고민이 생길 수 있다. 보통은 후자를 권장한다고 한다. 멀티 쓰레딩을 활용하면 병럴 처리가 되지 않을 뿐이지 어쨌건 여러 개의 쓰레드들을 활용하긴 하는데, 이 여러 개의 쓰레드를 활용하는 과정에서 컨텍스트 스위칭 비용이 발생한다. 이러한 점은 싱글 쓰레드로 비동기로 실행하는 것보다 코드의 성능을 악화시킨다.

 

결국 Python에서는 병렬 처리를 구현하기 위해서 멀티 프로세싱을 활용해야 한다. 물론 멀티 프로세싱이라고 해서 무조건적으로 장점만 있는 것은 아니다. 멀티 프로세싱이라 함은 프로세스를 여러 개 복제해서 실행한다는 것인데, 여러 프로세스 간에는 독립적인 환경이기 때문에 만약 프로세스 간에 자원을 공유해야 한다면 프로세스간 통신(IPC, Inter-Process Communication)을 수행해야 한다. 그런데 이 프로세스 간 통신을 수행할 때 직렬화/역직렬화 등과 같은 부가적인 과정이 수반되므로 비용이 들게 된다.

3. Python으로 병렬성(Parallelism) 구현하기

그러면 이제 파이썬으로 병렬성이라는 것을 구현해보도록 하자. CPU Bound 작업을 예시로 들어보자. 먼저 병렬 처리를 사용하지 않고 일반적인 동기 실행 코드부터 살펴보자.

 

import os
import threading
import time 


def calculate(n):
    print(f"{os.getpid()} process | {threading.get_ident()} thread | n: {n}")
    total = 0
    for i in range(n):
        for j in range(n):
            for k in range(n):
                total += i * j * k
    return total 


def main(nums):
    results = [calculate(num) for num in nums]
    print(results)


if __name__ == "__main__":
    start = time.time()
    main([300] * 10)
    print("elapsed-time:", time.time() - start)  # 12.4초

 

총 12.4초가 걸렸다. 그리고 출력 내용의 프로세스 ID와 쓰레드 ID를 보면 1개의 프로세스 내 1개의 쓰레드로만 실행된 것을 볼 수 있다.

 

일반적인 동기 실행으로 코드를 실행했을 때의 화면

 

다음은 멀티 쓰레딩 코드이다. Python에서 멀티 쓰레드는 built-in 라이브러리인 concurrent를 사용한다. 구체적인 사용법은 공식문서를 참조하자. 기본적인 사용법은 아래 예시와 같다.

 

import os
import threading
import time 
from concurrent.futures import ThreadPoolExecutor


def calculate(n):
    print(f"{os.getpid()} process | {threading.get_ident()} thread | n: {n}")
    total = 0
    for i in range(n):
        for j in range(n):
            for k in range(n):
                total += i * j * k
    return total 


def main(nums):
    with ThreadPoolExecutor(max_workers=10) as executor:
        results = list(executor.map(calculate, nums))
        print(results)


if __name__ == "__main__":
    start = time.time()
    main([300] * 10)
    print("elapsed-time:", time.time() - start)  # 12.3초

 

총 12.3초가 걸렸다. [목차 2-3]에서 언급했다시피 멀티 쓰레딩을 활용함에도 불구하고 일반적인 동기 실행 코드와 실행 시간의 차이가 거의 없는 것을 볼 수 있다. 아래 출력 화면을 보면 쓰레드 번호가 서로 다른 것을 보아 확실히 멀티 쓰레딩을 활용하긴 했지만 실행 시간 차이가 없는 것을 보니 병렬처리가 되지 않았음을 알 수 있다.

 

멀티 쓰레딩으로 코드를 실행했을 때의 화면

 

마지막으로는 멀티 프로세싱을 활용한 코드이다. 멀티 프로세싱도 마찬가지로 concurrent 라이브러리를 사용한다.

 

import os
import threading
import time 
from concurrent.futures import ProcessPoolExecutor


def calculate(n):
    print(f"{os.getpid()} process | {threading.get_ident()} thread | n: {n}")
    total = 0
    for i in range(n):
        for j in range(n):
            for k in range(n):
                total += i * j * k
    return total


def main(nums):
    with ProcessPoolExecutor(max_workers=10) as executor:
        results = list(executor.map(calculate, nums))
        print(results)


if __name__ == "__main__":
    start = time.time()
    main([300] * 10)
    print("elapsed-time:", time.time() - start)  # 1.6초

 

총 걸린 시간이 1.6초로 코드 실행 시간이 압도적으로 줄었음을 볼 수 있다. 그리고 출력 화면을 보면 다음과 같이 쓰레드 ID는 고정이되 프로세스 ID가 서로 다른 것을 볼 수 있다. 즉, 멀티 프로세싱으로 각 프로세스가 1개의 쓰레드로만 실행했음을 알 수 있다.

 

멀티 프로세싱으로 코드를 실행했을 때의 화면

 

반응형