Post

파이썬 쓰레드

파이썬 쓰레드

지난 이야기

  • 프로세스: 실행되고 있는 프로그램
  • 작업: 함수, 메소드 같이 프로그램 내에서 처리되는 일
  • 쓰레드: 프로세스 내에 있는 작업의 최소 실행 단위
  • 멀티프로세싱: 프로세스를 여러개 생성해 여러 작업을 동시 처리하는 기법
    • 하나의 프로세스는 하나의 작업
  • 멀티쓰레딩: 쓰레드를 여러개 생성해 여러 작업을 동시 처리하는 기법
    • 하나의 프로세스가 여러 작업
  • 멀티코어 프로세서에서는 운영체제가 각 코어에 쓰레드 및 프로세스를 할당하므로 동시 처리성능이 더욱 올라간다.
  • 쓰레드들은 프로세스 메모리를 일부 공유한다.
    • 따라서 멀티쓰레딩이 멀티프로세싱보다 자원 장비가 적다

지난 시간에는 이론적인 쓰레드와 프로세스의 차이, 멀티프로세싱과 멀티쓰레딩의 장단점을 알아보았다.
이제 파이썬을 사용해 실전 적용해보자.

시작하기 전-프로세서에서의 쓰레드는

요즘 CPU들을 보면 4코어 8쓰레드, 6코어 12쓰레드 등 식으로 광고한다.
여기서도 쓰레드라는 말이 나오는데, 이 쓰레드도 앞전에 말한 쓰레드와 동일한 개념인지 의문이 생기는 자가 있을 것이다.
결론부터 말하자면, 둘은 비슷한 의미이다. 다만 그게 하드웨어 관점이냐 소프트웨어 관점이냐의 차이인 것이다.

  • 멀티-코어 프로세서는 여러 코어로 구성된 CPU라고 배웠는데, 이 코어는 앞서 말했듯이 일종의 미니 프로세서이다.
  • 이 코어는 하나의 프로세스 쓰레드와도 같아, 한 번에 하나의 작업만 처리할 수 있다.
  • 하지만 인텔 사에서 SMT라는 기술을 개발해, 코어 하나가 한번에 두 가지 작업을 처리하도록 만들어벼렸다.
    • 즉 CPU 쓰레드는 일종의 논리적인 코어라고 보면 된다.
  • 이전에 배운 프로세스 쓰레딩을 생각해 보면…
    • 프로세스는 원래 한번에 한가지 작업밖에 못했다.-프로세스를 코어에 대입
    • 그런데 프로세스 쓰레딩이라는 개념이 개발되어 이제 한번에 여러 작업을 수행할 수 있다.-프로세스 쓰레딩을 SMT에 대입

멀티쓰레딩

  • 멀티프로세싱 때 파이썬은 기본적으로 프로세스 하나만을 사용한다고 언급했다. 쓰레딩을 배웠으므로 엄밀하게 말해보겠다.
    • 파이썬 코드를 실행시키면(프로세스 생성) 쓰레드 하나가 생성되고 그 쓰레드에서 모든 작업을 처리한다.
    • 이전 시간에 하나의 쓰레드는 하나의 작업만 할 수 있다 말했다.
    • 따라서 코드 내 함수가 몇 개든 몇십 개든 함수를 순차적으로 실행할 수밖에 없다.
  • 멀티프로세싱은 약간의 꼼수를 부려 하나의 프로세스 내에서 새로운 프로세스를 생성하는 식으로 여러 작업을 동시 실행하는 방식이었다.
  • 이번에 언급할 멀티쓰레딩은 위 멀티프로세싱에서 새로운 프로세스 생성을 쓰레드 생성으로 바꾸기만 하면 되는, 비교적 간단한(?) 원리이다.

파이썬에서의 사용법

1
from threading import Thread

이렇게 쓰레드 라이브러리를 불러온다.

1
2
3
(변수명) = Thread(target=(실행할 함수), args=(함수 인자))
(변수명).start()
(변수명).join()
  • multiprocessing 라이브러리의 Process에서 Process를 Thread로 바꾸기만 하면 된다.
  • 나머지 기능(start,join 등)들도 Process와 동일하다.
    • 마찬가지로 인자 하나일 경우에 괄호 씌워주고 쉼표 붙여줘야 한다.
  • 사실 본인의 설명이 주객전도된 경우로, 원래 파이썬에서는 Thread가 먼저 구현되었고 그 다음 Process가 나왔다.

예제

이번에는 시간 차이를 명확하게 내기 위해 실행 시간을 조금 늘려보겠다.
1부터 99999999까지를 더해 출력하는 작업이다.
우선 쓰레드를 사용하지 않았을 경우

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from threading import Thread
from time import time
tosso = [i for i in range(1,99999999)]
hap = 0
def duhagi(su):
    global hap
    for i in su:
        hap+=i
    return hap

if __name__ == "__main__":
  sijak = time()
  duhagi(tosso)
  print(hap)
  print(time()-sijak)
4999999850000001 결과
9.91478180885315 시간

이번엔 쓰레딩 4개를 사용해 똑같은 작업을 할당해보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from threading import Thread
from time import time
tosso = [i for i in range(1,99999999)]
hap = 0
def duhagi(su):
    global hap
    for i in su:
        hap+=i
    return hap

if __name__ == "__main__":
  sijak = time()
  thread1 = Thread(target=duhagi,args=(tosso[:25000000],))
  thread2 = Thread(target=duhagi,args=(tosso[25000000:50000000],))
  thread3 = Thread(target=duhagi,args=(tosso[50000000:75000000],))
  thread4 = Thread(target=duhagi,args=(tosso[75000000:],))

  thread1.start()
  thread2.start()
  thread3.start()
  thread4.start()

  thread1.join()
  thread2.join()
  thread3.join()
  thread4.join()

  print(hap)
  print(time()-sijak)
1766273827059995 결과
11.48702883720398 시간
재시도
1792294138999865 결과
11.480373620986938 시간

엥? 오히려 시간이 더 걸릴뿐만 아니라 결과값도 오답이다.
심지어 매번 실행할때마다 결과가 달라진다.
이쯤되면 어안이 벙벙해진다…

이런 일이 왜 생기는가

지난 시간에 간략하게 말했지만 이 일은 임계 영역 문제와 관련이 있다.
위 쓰레딩 코드를 잘 보면, 더하기 결과가 저장되는 hap은 전역변수이다.
지난 시간에 전역변수는 메모리의 data라는 곳에 저장된다는 것을 배웠고, 쓰레드는 data 영역을 공유한다는 것도 배웠다.
각 쓰레드들이 실행되면서 전역변수 값을 바꿔 저장할텐데, 이 전역변수 값을 바꾸는 원리를 알아봐야 한다.

  • 쓰레드 1이 hap을 메모리에서 레지스터로 가져온다-펫치(fetch)
  • 쓰레드 1이 값을 어떻게 바꿀지를 알아온다-디코드(decode)
  • 쓰레드 1이 값을 바꾸고 바뀐 레지스터 값을 다시 hap으로 저장한다-익세큐트(execute)

각 쓰레드는 값을 더할 때마다 위 3단계 작업을 반복한다.
여기서 문제가 생기는데, 만약 쓰레드 1의 펫치-익세큐트 사이에 쓰레드 2의 펫치가 실행되면 어떻게 될까?

  • 지금 hap의 값은 10이다.
    • 쓰레드 1은 이 hap을 가져와 10을 더하고 있다.
    • 쓰레드 2도 hap을 가져와 25000000을 더하려고 한다.
  • 원래대로라면 쓰레드 1,2가 끝나고 hap은 25000020이 되어야 한다.
  • 그런데 실행순서가 이렇게 꼬여버린다.
hap쓰레드1쓰레드1레지스터쓰레드2쓰레드2레지스터
10펫치10xx
10디코드10xx
10xx펫치10
20익세큐트20xx
20xx디코드10
25000010xx익세큐트25000010

따라서 값은 2500020이 아닌 2500010이 되어버린 것이다.
이렇게 쓰레드가 공유하는 메모리 내 데이터에 접근하는 코드 부분을 임계 영역이라 한다.
여기서는 duhagi 함수 내 hap을 더하는 for문이 임계 영역에 해당된다고 볼 수 있다.

위에서 시간이 늘어나고, 매번 실행한 결과가 다르게 나타나는 까닭도 이렇게 실행 순서가 꼬여버려서이다.

이를 해결하기 위해서는

가장 간단한 방법으로는 한 쓰레드가 임계 영역에 들어가는 동안, 다른 쓰레드가 임계 영역에 못 들어가게 막아버리면 된다.
다시 말해서…

  • 한 쓰레드가 임계 영역 내 코드를 실행중이다.
  • 다른 쓰레드들은 임계 영역 전의 코드에서 대기해야 한다.
  • 그 쓰레드가 임계 영역을 빠져나가면, 다른 쓰레드 중 하나가 임계 영역에 들어간다.
  • 반복…

이를 파이썬에서 활용하는 방법 중 하나로 threading 라이브러리에서 제공하는 lock이 있다.

1
2
from threading import Lock
mylock = Lock()

이렇게 Lock() 객체를 전역변수로 선언해준다.
그리고 임계 영역 구간을

1
2
with mylock:
    (임계 영역 코드)

Lock() 객체의 with로 감싸주면 된다. 또한

1
2
3
mylock.acquire()
(임계 영역 코드)
mylock.release()

이렇게 acquire(), release()를 사용해 구현할 수도 있다.
위 예제에서 lock을 구현해 보면

1
2
3
4
5
6
def duhagi(su):
    global hap
    with tissi:
        for i in su:
            hap+=i
    return hap

이렇게 hap을 더해주는 부분을 lock으로 감쌌다.

4999999850000001 결과
6.11182975769043 시간

이제 값이 정상적으로 나오고, 시간도 정상적으로 단축됨을 확인할 수 있다.

다만 이 lock도 완벽한 해결 방법은 아니다. 이 lock도 꼬여버릴 수 있는데, 그러면 교착 상태(데드락)라는 상황이 발생할 수 있다.

  • 위에서는 하나의 lock만 사용했지만, 만약 임계영역 구간이 하나 더 있어 lock 객체를 하나 더 추가했다고 하자.
  • 쓰레드 1은 첫번째 Lock에 들어간 상태고 쓰레드 2는 두번째 Lock에 들어간 상태이다.
  • 쓰레드 1은 두번째 Lock에 들어가고 싶고, 쓰레드 2는 첫번째 Lock에 들어가고 싶어한다.
  • 이상적으론 서로 Lock을 빠져나간 뒤 다른 Lock에 들어가면 되지만, 이 순서가 꼬여 서로 Lock을 빠져나가지 못한 채 무한 대기하는 상황이 발생해버린다.
  • 이를 교착 상태라고 한다.

이를 해결하는 방법으론 lock의 발전된 형태인 뮤텍스, 세마포어 등이 있다.
이를 설명하기에는 여백이 부족하므로 다음에 설명하겠다.

다음 시간에…

  • 알고리듬-bfs
  • 비동기 처리
  • 새로운 멀티프로세싱과 쓰레딩-concurrent.futures
This post is licensed under CC BY 4.0 by the author.