파이썬 Thread

5984 단어 파이썬파이썬

1. Thread 란?


PC에는 윈도우, macOS, 리눅스와 같은 운영체제가 설치되어 있다. 운영체제는 컴퓨터를 전체적으로 관리하는 매니저 역할을 한다. 우리가 프로그램이라고 부르는 것들은 운영체제 위에서 동작한다. 프로그램이 메모리에 올라가서 실행 중인 것을 프로세스(process)라고 부른다. 이러한 프로세스의 실행 단위를 스레드라고 한다. 프로세스는 최소 하나 이상의 스레드를 갖으며 경우에 따라 여러 스레드를 가질 수도 있다.

여러 스레드가 교차적으로 일을 수행하는 방법을 동시성이라고 한다.

동시성이란 흔히 말하는 멀티태스킹이다. 해야 할 업무가 A, B, C 가 있을 때 일을 잘게 분할한 후 이를 조금씩 번갈아 가면서 처리하는 방식을 의미한다. 한 순간에 하나의 일을 하고 있지만 이를 아주 빨리 번갈아 할 수 있다면 마치 동시에 처리하는 것처럼 보인다는 뜻이다.

2. GIL


파이썬에서는 Global Interpreter Lock(GIL) 이라는 개념이 존재한다.
GIL 이란 무엇일까? 파이썬 인터프리터가 하나의 스레드만 하나의 바이트코드를 실행 시킬 수 있도록 해주는 Lock이다.

하나의 스레드에 모든 자원을 허락하고 그 후에는 Lock을 걸어 다른 스레드는 실행할 수 없게 막아버리는 것이다.

아래 그림은 파이썬에서 3개의 스레드가 동작하는 예시이다. 각각의 스레드는 GIL로 동작하며, 이 때 다른 스레드는 모두 동작을 멈추게 된다.

또한, 멀티스레드의 경우 thread context switch(쓰레드 전환이라고 이해하자)에 따른 메모리도 필요하기 때문에 오히려 싱글스레드보다 시간이 오래 걸리는 문제가 있다.

파이썬에서는 왜 GIL을 사용하는 걸까?

파이썬에서는 기본적으로 Garbage collection(GC)과 Reference counting 을 통해서 할당된 메모리를 관리한다. GC는 과거 수동적으로 메모리를 관리하던 것을 자동으로 메모리 관리를 해주게 하는 기특한 녀석이다.

레퍼런스 카운팅부터 한번 알아보자.

구 파이썬(CPtyhon)에서의 GC는 Reference counting 방식인데, 파이썬에서 객체를 만들 때마다 기본 C객체에서는 파이썬 유형(list, dict, 함수)과 reference count가 생성된다.

레퍼런스 카운트는 객체가 참조될 때마다 증가하고 객체의 참조가 해제될 때 감소한다. 즉, 객체의 refence count가 0이 되면 객체의 메모리 할당이 해제된다.

기본적으로 위에서 설명한 참조 횟수가 0이 된 객체를 메모리에서 해제하는 레퍼런스 카운팅 방식을 사용하지만, 참조 횟수가 0이 아니고 도달할 수 없지만 reference cycles(순환 참조)가 발생했을 때는 GC로 이런 상황을 해결한다.(번역체는 어렵다 아래 예로 조금 더 살펴보자)

순환참조의 간단한 예는 자기 자신을 참조하는 객체이다. 아래 코드를 살펴보자.

L = [] # Reference count가 1이 되었다.
L.append(L) # Reference count가 2가 되었다.
del L # Reference count가 1이 되었다.

L의 참조 횟수는 1이지만, 이 객체는 더 이상 접근할 수 없으며 레퍼런스 카운팅 방식으로는 메모리에서 해제될 수 없다.

서로를 참조하는 객체도 순환 참조의 예이다.

a = Foo() # 빈 함수 대입 메모리 주소값 0x60 / Reference count가 1이 되었다.
b = Foo() # 빈 함수 대입 메모리 주소값 0xa8 / Reference count가 1이 되었다.
a.x = b # 0x60의 x는 0xa8을 가리키고 있다.
		# 0x60의 Reference count 는 a와 b.x로 2다
b.x = a # 0xa8의 x는 0x60을 가리키고 있다.
		# 0xa8의 Reference count 는 b와 a.x로 2다
del a # 0x60의 Reference count는 1로 감소했다.
del b # 0xa8의 Reference count는 1로 감소했다.

이러한 상태에서 0x60.x0xa8.x가 서로를 참조하고 있기 때문에 레퍼런스 카운트는 둘다 1이지만 0에 도달할 수 없는 가비지가 된다.

이럴 때 가비지 컬렉션이 나타나 뿅하고 해결을 도와준다.

GIL을 이해하기 위해서는 우선 여기까지만 알아보자. 후에 더 자세하게 학습을 진행하자.

3. GIL 사용 이유


위에서 살펴본 것처럼 파이썬의 모든 객체는 레퍼런스 카운트(해당 변수가 참조된 수)를 저장하고 있다. 이 때, 멀티스레드인 경우 여러 스레드가 하나의 객체를 사용한다면 레퍼런스 카운트를 관리하기 위해서 모든 객체에 대한 Lock이 필요할 것이다. 이러한 비효율을 막기 위해서 파이썬에서 GIL을 사용하게 되었다.
하나의 Lock을 통해서 모든 객체들에 대한 레퍼런스 카운트의 동기화(기억하자) 문제를 해결한 것이다.

4. 쓰레딩 모듈을 활용하기


아래 코드는 쓰레딩 모듈을 활용해서 0에서 5천만까지 진행할 때 shared_number가 얼마나 증가하는지(효율이 얼마나 나오는지) 확인하는 예제코드이다.

import threading
import time

shared_number = 0

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    for i in range(number):
        shared_number += 1

def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)
    for i in range(number):
        shared_number += 1


if __name__ == "__main__":

    threads = [ ]

    start_time = time.time()
    t1 = threading.Thread( target= thread_1, args=(50000000,) )
    t1.start()
    threads.append(t1)

    t2 = threading.Thread( target= thread_2, args=(50000000,) )
    t2.start()
    threads.append(t2)


    for t in threads:
        t.join()

    print("--- %s seconds ---" % (time.time() - start_time))

    print("shared_number=",end=""), print(shared_number)
    print("end of main")
>>>
number = 50000000
number = 50000000
--- 4.035499095916748 seconds ---
shared_number=63779654
end of main

shared_number 가 1억번이 안되는 이유는 무엇일까? 글로벌 변수이기 때문에, 중첩되어 사용되야 하지 않을까?

아니다. 전역 변수에 각기 다른 thread가 접근하게 되면서 카운팅이 꼬이게 되었다. a += 1 은 보통 3가지 명령으로 진행되고, cpu는 명령어를 하나씩 실행한다.

  • a의 값을 메모리에서 레시스터로 불러온다.
  • 레지스터에서 더한다.
  • 더한 값을 실제로 a가 있는 메모리에 저장한다.

thread_1이 먼저 진행이 되었는데 저 마지막 메모리에 저장도 하기전에 thread_2가 실행이 되었다. 그럼 메모리값이 계속 계속 바뀔 것이다. 0 -> 1 -> 0 -> 1 이런식으로 말이다.

그럼 저 shared_number가 일억번이 되게 셋팅하려면 어떻게 해야할까?

파이썬은 싱글 스레드를 이용하고 있고, 그 방법은 GIL이라고 위에서 알아봤다. 동기화를 억제하기 위해서 사용한다는 것, 즉 스레드끼리 동기화가 안되었기 때문에 위 같은 상황이 벌어진 것이 아닐까? 그렇다면 동기화를 허용해준다면, 카운팅이 정상적으로 작동하지 않을까?

그럼 한번 thread를 동기화 하는 방법을 사용하자.

동기화하는 방법 중 하나인 lock()을 이용해보자.
아래 코드를 보면 쉽게 이해할 수 있다.

import threading	#threading 라이브러리를 이용
import time			#time 라이브러리를 이용
from threading import Lock	#이미 위에서 해줬지만, threading 의 Lock 을 가지고 왔다.

shared_number = 0
lock = Lock()	#Lock을 편하게 해주기 위해서 lock 이라는 변수에 객체를 만들어줬다.

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    
    lock.acquire() # for문 실행전에 lock을 걸어서 다른 스레드가 전역변수 접근을 못하게 했다.
    for i in range(number):
        shared_number += 1
    lock.release() # lock 해제

def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)

    lock.acquire() # thread_2 lock 걸기
    for i in range(number):
        shared_number += 1
    lock.release() # thread_2 lock 해제

if __name__ == "__main__":

    threads = [ ]

    start_time = time.time()
    t1 = threading.Thread( target= thread_1, args=(50000000,) )
    t1.start()
    threads.append(t1)

    t2 = threading.Thread( target= thread_2, args=(50000000,) )
    t2.start()
    threads.append(t2)

    for t in threads:
        t.join()	# join을 사용해서 sub스레드가 끝날 때 까지 main 스레드가 기다려주자

    print("--- %s seconds ---" % (time.time() - start_time))

    print("shared_number=",end=""), print(shared_number)
    print("end of main")
>>>
number = 50000000
number = 50000000
--- 4.0316081047058105 seconds ---
shared_number=100000000
end of main

좋은 웹페이지 즐겨찾기