Python Basics (6)
동시성 (Concurrency) 과 병렬성 (Parallelism) 은 프로그래밍에서 아주 중요한 요소이다. 이 두 가지의 특성을 이용하여 한정적인 자원에서 최대한의 성능을 이끌어낼 수 있다.
병렬성 (Parallelism) 은 물리적으로 동시에 여러 가지 작업을 연산하는 것을 의미한다. 컴퓨터에서 하나의 CPU 는 한 번에 하나의 연산만 수행할 수 있기 때문에, 멀티코어 CPU 가 아니라면 병렬성을 구현할 수 없다고 한다. 병렬처리를 진행할 때, 병렬화하는 주체를 어떤 것으로 할지 선택할 수 있다. 작업을 병렬화하는 것은 작업 병렬성, 작업하는 데이터를 병렬화하는 것은 데이터 병렬성이라고 한다.
작업 병렬성은 동시에 구분된 작업을 수행하는 것이다. 여러 개의 CPU 에서 각각 다른 작업을 수행하는 것이다. 데이터 병렬성은 동시에 같은 작업을 수행한다. 하지만, 작업을 처리하는 데이터가 각기 다르다. 이 경우에는 여러 개의 CPU 에서 다른 데이터에 같은 작업을 처리하는 것이다.
동시성 (Concurrency) 은 서로 독립적인 작업을 작은 단위로 나누고 번갈아가며 연산하는 것을 말한다. 컴퓨터는 한 번에 하나의 연산밖에 수행하지 못한다. 그래서, 큰 덩어리의 작업을 처리하고 있으면 그 작업이 끝날 때까지 다른 작업은 대기해야 한다. 이 문제를 해결하기 위해 큰 덩어리의 작업을 작은 단위로 나누고 나누어진 작은 단위의 작업들을 번갈아가며 처리해서 하나의 작업이 끝나지 않아도 여러가지 작업을 조금씩 수행할 수 있도록 한 것이다.
이제 프로세스 (Process) 와 쓰레드 (Thread) 에 대해서 알아보아야 한다.
프로세스 (Process) 는 실행 중인 프로그램이다. 프로세스는 운영체제로부터 자원을 할당받아서 프로그램을 실행하는 작업의 최소단위인 것이다. 프로세스는 크게 2 가지로 분리할 수 있다. 커널(운영체제) 레벨과 유저레벨을 분리하는 것처럼 커널(운영체제)에서 실행되는 프로세스와 유저레벨에서 실행되는 프로세스로 나뉜다.
프로세스는 서로 독립적인 메모리, 주소공간을 할당받는다. 그래서 프로세스 간 정보를 교환하기 위해서는 별도의 통신 채널이 필요하다. 이 통신 채널을 IPC(Inter-Process-Communication) 라고 부른다. IPC 의 대표적인 예로 socket, character device, shared memory 등이 있다고 한다.
쓰레드 (Thread) 는프로세스 안에서 프로세스가 할당받은 자원을 이용하여 프로그램의 명령을 실행하는 최소단위이다. Thread 는 한 프로세스 내에서 실행의 흐름이나 프로세스 내의 주소공간, 메모리 같은 자원들을 프로세스 내의 다른 쓰레드들과 공유할 수 있다.
쓰레드는 프로세스를 생성하는 비용을 줄이기 위해 고안된 개념이다. 프로세스를 만들어서 작업을 처리하려면 프로세스르 만들기 위한 비용이 들게 된다. 프로세스에 자원을 할당, 서로 다른 프로세스 간 정보를 교환하기 위해 IPC 구현, 이를 통해서 정보를 교환해야 한다. 이러한 비용을 줄이기 위해 쓰레드가 등장했고, 프로세스 안에서 쓰레드를 여러 개 만들어 프로세스를 여러 번 만드는 비용을 줄이게 되었다.
하나의 프로세스 안에는 최소 하나의 쓰레드가 존재한다. 필요에 따라 여러 개의 쓰레드를 만들어서 명령을 처리하기도 한다. 여러 개의 쓰레드가 프로세스 안에 존재할 때 각 쓰레드에서 사용하는 자원은 모두 공유된다. 그래서 IPC 와 같은 별도의 통신채널을 만들 필요는 없지만 자원의 무결성과 동기화를 보장하기 위한 처리가 필요하다.
이제 멀티프로세싱(Multiprocessing)과 멀티쓰레딩(Multithreading)에 대해서 알아볼 수 있다.
멀테프로세싱은 하나의 컴퓨터에서 2 개 이상의 CPU 를 가지고있는 것을 뜻한다. 여기서, 단순히 CPU 가 2 개 이상인 것이 아니라 2 개의 CPU 가 서로의 메모리, 기타 입력 장치들을 공유하는 구조여야 한다. 멀티프로세싱을 구현하기 위해 여러가지 방식을 사용할 수 있는데, 대표적으로 SMP 와 ASMP 가 있다.
SMP (Symmetric Multiprocessing) 은 대칭적인 멀티프로세싱이라는 뜻으로 일반적으로 우리가 사용하는 컴퓨터나 서버급 장비를 생각하면 된다. 메인보드에 2 개 이상의 CPU 를 장착할 수 있고, 이 CPU 들이 대칭을 이루는 구조인 것이다. SMP 에서는 모든 CPU 들이 동등한 권리를 가진다.
ASMP (Asymmetric Multiprocessing) 은 비대칭적인 멀티프로세싱으로 SMP 와 다르게 CPU 마다 다른 권리를 가지고 있다. CPU 마다 다른 물리적인 공간을 가질 수 있고, 특정 프로세스는 특정 CPU 에서만 동작하도록 할 수 있다.
멀티쓰레딩은 하나의 CPU 나 멀티 코어 CPU 에서 하나의 코어가 동시에 여러 개의 프로세스나 쓰레드를 처리하는 것을 의미한다. 멀티쓰레딩은 프로세스나 쓰레드가 코어의 자원을 공유한다. 멀티쓰레딩은 한정된 자원으로 여러 가지 작업을 동시에 처리하는 개념인 것이다. 여러가지 작업을 조금씩 스케줄링하며 처리함으로써 전반적인 성능을 끌어올리는 방식인 것이다.
쓰레드들은 프로세스의 자원을 공유하게 되기 때문에 자원의 무결성과 동기화를 위한 처리를 해주어야 한다. Python 에서 쓰레딩을 구현할 때 GIL 이 그 역할을 하게된다. 하지만, 이 GIL 때문에 역으로 쓰레드가 쓰레드의 기능을 온전히 하지 못한다고 한다. 이유는 GIL 이 Global Lock 이기 때문이라고 한다.
Lock 에는 여러 종류가 있지만 크게는 Coarse-grained Lock 과 Fine-grained Lock 으로, 둘로 나뉘게 된다. Coarse-grained Lock 은 큰 단위로 Lock 을 잡는 방식이고, Fine-grained Lock 은 작은 단위로 나눠서 잡는 방식이다. 동시성을 극대화하기 위해 하나의 CPU 에 코어를 여러 개 만들어서 여러 작업을 수행할 수 있도록 만들었지만, CPU 의 자원은 코어가 여러 개더라도 한 번에 하나만 사용할 수 있다. 그래서 멀티코어 CPU 에서는 Coarse-grained Lock 을 사용하는 것보다 Fine-grained Lock 방식으로 락을 세분화하여 잡는 것이 좋다고 한다. 그렇게해야 하나의 작업이 Lock 을 잡고있는 시간이 줄어들게 되고 여러 쓰레드에서 Lock 을 잡을 수 있는 확률이 높아져서 작업을 빠르게 처리할 수 있다고 한다. 반면, 싱글 코어 CPU 에서는 굳이 Lock 을 작은 단위로 잡을 필요가 없다. 오히려 락을 빈번하게 잡게되면 락을 기다리기 위한 시간, 문맥 교환 비용이 들기 때문에 락을 덜 잡는 방향으로 가는 것이 성능에 유리하다고 한다.
여기서 GIL 은 Coarse-grained Lock 방식을 사용한다. 그래서 사실 CPython 에서 쓰레드를 여러 개 생성하는 것은 생각보다 효율적이지는 않다고 한다. 실제로 Python 문서에서도 CPython 에서는 GIL 의 영향 때문에 멀티 코어 CPU 장비에서는 쓰레드보다는 멀테프로세싱이나 concurrent.futures.ProcessPoolExecutor 을 사용하라고 권고한다.
Python 에서 쓰레딩을 실제로 구현하기 위해 threading 모듈을 사용할 수 있다.
import threading
def worker(count):
print("name: %s, argument: %s" % (threading.current_thread().name, count))
def main():
for i in range(5):
t = threading.Thread(target=worker, name="thread %i"%i, args=(i,))
t.start()
if __name__ == "__main__":
main()
threading 모듈을 사용해서 쓰레드를 만들기 위해서는, threading 모듈의 Thread 클래스에 쓰레드로 처리할 함수를 넣고 초기화한다. 그리고 start() 메서드를 실행하면 된다. (쓰레드를 초기화할 때 쓰레드의 이름, 매개변수도 설정할 수 있다) 이 외에도 class 를 만들 때 threading 의 Thread 클래스를 상속받게끔 하여 클래스를 작성해줄 수도 있다.
multiprocessing 을 사용할 때는 threading 이 아닌 multiprocessing 모듈을 사용하면 된다. 그 외 기초적인 사용 방법은 동일하다고 볼 수 있다.
import threading
import multiprocessing
def worker(count):
print("name: %s, argument: %s" % (threading.current_thread().name, count))
def main():
for i in range(5):
t = threading.Thread(target=worker, name="thread %i"%i, args=(i,))
t.start()
p = multiprocessing.Process(target=worker, args=(i,))
p.start()
if __name__ == "__main__":
main()
Reference:
파이썬답게 코딩하기