Process, Thread, GIL

프로세스

프로세스(process): 각각의 프로그램이 메모리에 올라가 실행되는 과정.

멀티 프로세스

멀티 프로세스(mutil-process)는 여러 프로세스가 동시에 (빠르게 번갈아가며) 처리되는 과정이다. 

예를 들어, 음악 재생 + 코드 에디터 + 브라우저를 실행했다면 3개의 프로세스가 동시에 동작하게 된다. 하지만 실제 컴퓨터는 프로세스를 동시에 작업하지 않는다. 아래 그림은 여러 개의 프로세스가 동작하는 과정을 시간에 따라 나타낸 것이다. 

프로세스 1과 프로세스 2가 매우 빠른 속도로 번갈아가며 실행된다. 이 과정이 마치 동시에 실행되는 것처럼 보인다.

1번 프로세스와 2번 프로세스가 번갈아가며 실행된다. 이 과정이 매우 빠르게 실행되기 때문에 마치 동시에 두 작업이 진행되는 것처럼 보이는 것이다. 이 과정을 context switching이라 부르고, 동시성(concurrency)을 가진다고 표현한다. 

그런데 context switching은 비효율적이다. 프로세스는 각자 독립적인 메모리 영역을 가진다. 프로세스 1이 실행되면, 프로세스 1에 필요한 데이터가 램에 올라간다. 그리고 프로세스 2가 실행되면 프로세스 2에 필요한 데이터가 램에 올라가게 된다. 따라서 context-swithcing이 일어날 때마다 많은 비용을 사용하게 된다. 

* 프로세스가 다른 프로세스에 접근하기 위해서는 IPC(Inter Process communication)을 사용한다. 


스레드

스레드(thread): 프로세스 내의 여러 작업 흐름.

하나의 프로세스도 세부적으로 여러 작업의 흐름을 가지고 있고, 각각의 작업 단위를 스레드라고 부른다. 모든 프로세스는 하나 이상의 스레드를 가지게 된다. 

멀티 스레드

멀티 스레드(muti-thread)는 멀티 프로세스와 같이 여러 작업을 빠르게 번갈아가며 실행하는 것을 말한다. 멀티 프로세스는 독립적인 메모리 영역을 가지고 있던 것과 다르게 동일한 프로세스 내의 쓰레드는 특정 영역(heap, code, data) 공유한다. 따라서, 공유 영역을 메모리에 올린 상태에서 개별적인 영역(stack, registers)만을 변경하게 된다. 이렇게 되면 모든 데이터를 올렸다 내리는 것보다 적은 비용으로 작업을 수행할 수 있다. (소스코드나 전역 변수 등은 공유하고, 지역변수와 주소 값 등은 독립적으로 관리하는 식이다.)

하지만 프로세스 내의 스레드들은 공유 영역을 가지고 있기 때문에 하나의 스레드에 문제가 발생하면 다른 스레드에도 영향을 미칠 수 있다. 또한 스레드 간의 동기화 문제도 신경써야하는 등 사용하기 까다롭다는 단점이 있다. 


GIL

GIL(Global Interpreter Lock)은 한 쓰레드만 파이썬 인터프리터에 접근할 수 있도록 하는 뮤텍스(mutual exclusion)의 일종이다. GIL을 사용하는 이유는 경쟁 상태(race condition)의 위험 때문이다. 파이썬에는 Garbage Collector(GC)가 존재한다. 파이썬 객체가 몇 번 참조되는지 객체 참조(reference count)를 할 때 사용한다. 

import sys

class Obj:
    pass

a = Obj()  # a 참조: 1회
sys.getrefcount(a)  # a 참조: 2회 (임시)
>>> 2

b = a  # a 참조: 2회
sys.getrefcount(a)  # a 참조: 3회 (임시)
>>> 3

b = 0  # a 참조: 1회
sys.getrefcount(a)  # a 참조: 2회 (임시)
>>> 2

(1) a가 생성된 후, (2) getrefcount에서 임시로 참조되며 총 2번의 참조가 발생한다. (3) b가 a를 참조하고 getrefcount에서 임시로 참조되며 총 3번의 참조가 일어난다. b에서 a에 대한 참조가 사라지면 다시 1로 바뀌게 된다. 이렇게 객체를 몇 번 참조했는지 세는 것을 reference count라고 한다. 이때, 참조가 0이 되면 파이썬에서 더 이상 객체를 참조하지 않기 때문에 GC에 의해 메모리에서 삭제된다. 만약 여러 스레드가 접근할 수 있도록 하면 예상치 못한 충돌이 발생할 가능성이 생긴다. 따라서, (Lock)을 걸어 하나의 스레드만 접근할 수 있도록 허용한다.

이러한 GIL의 특성 때문에 단순 연산의 경우, 멀티 스레드를 활용하는 것보다 싱글 스레드를 사용하는 것이 오히려 효율적이다. 병렬적인 처리가 안 되고 context switching만 실행하기 때문에, 오히려 시간이 더 많이 드는 경우가 생긴다. 


threading

threading 모듈은 멀티스레딩을 제공한다. Theard(target=함수, args=(함수인자)) 형태로 사용하며, start로 실행하고, join으로 종료를 대기한다. 

from threading import Thread
from time import sleep

def test(sec, name):
    for i in range(4):
        sleep(sec)
        print(f"{name} - {i}")

thr1 = Thread(target=test, args=(3, "A"))
thr2 = Thread(target=test, args=(6, "B"))

thr1.start()
thr2.start()
thr1.join()
thr2.join()
A - 0
B - 0
A - 1
A - 2
B - 1
A - 3
B - 2
B - 3

A와 B가 동시에 진행되기 때문에 번갈아가며 출력되는 것을 볼 수 있다. 

Lock을 이용해 쓰레드의 접근을 제한한다. acquire을 통해 접근 권한을 얻고 release를 통해 권한을 해제한다. 

from threading import Thread, Lock
from time import sleep

lock = Lock()

def test(sec, name):
    lock.acquire()  # 권한 획득
    for i in range(4):
        sleep(sec)
        print(f"{name} - {i}")
    lock.release()  # 권한 해제

thr1 = Thread(target=test, args=(3, "A"))
thr2 = Thread(target=test, args=(6, "B"))

thr1.start()
thr2.start()
thr1.join()
thr2.join()
A - 0
A - 1
A - 2
A - 3
B - 0
B - 1
B - 2
B - 3