King of Silicon Valley 2021. 10. 3. 19:55
728x90

스레드의 개념과 파이썬에서 스레드 사용및 문제점에 대해 알아보자

🧶 What is 스레드?

스레드(thread)는 어떠한 프로그램 내에서, 특히 프로세스 내에서 실행되는 흐름의 단위를 말한다. 일반적으로 한 프로그램은 하나의 스레드를 가지고 있지만, 프로그램 환경에 따라 둘 이상의 스레드를 동시에 실행할 수 있다. 이러한 실행 방식을 멀티스레드(multithread)라고 한다.

출처:위키백과 스레드

스레드의 특징

  • 하나의 프로세스에 여러개의 스레드 생성 가능
  • 스레드들은 동시에 실행 가능
  • 프로세스 안에 있으므로,프로세스의 데이터를 모두 접근 가능

스레드는 각기 실행이 가능한 stack이 존재한다.

멀티 스레드

  • 소프트웨워의 병행 작업 처리를 위해 멀티 스레드를 사용한다.

스레드의 장점

  • IPC기법과 같이 프로세스간 자원 공유를 위해 번거로운 작업이 필요없다. ->>contextswitcing에 리소스가 적게든다.
  • 프로세스 안에 있으므로, 프로세스의 데이터에 모두 접근 가능하다.

스레드의 단점

  • 스레드 중 한 스레드만 문제가 있어도, 전체 프로세스가 영향을 받음 -> 하나의 프로세스 자식 스레드들이 부모 프로세스의 자원을 공유하기때문

  • 스레드를 많이생성하면, Context Switching이 많이 일어나, 성능 저하 -->> 스레드를 많이 생성하면, 모든 스레드를 스케쥴링해야 하므로, Cintext Switching이 빈번할 수 밖에 없음

스레드 VS 프로세스

  • 프로세스는 독립적, 스레드는 프로세스의 서브셋
  • 프로세슨 각각 독립적인 자원을 가짐, 스레드는 프로세스 자원공유
  • 프로세스는 자신만의 주소영역을 가짐, 스레드는 주소영역 공유
  • 프로세스간에는 IPC 기법으로 통신해야함, 스레드는 필요 없음

😉 스레드의 문제점

  • 파이썬에서 스레드를 사용할 때 문제점을 알아보자

파이썬은 싱글스레드다!

  • 스레드의 장점 중 하나가 병렬 처리로 작업의 속도를 향상인데 아쉽게도 파이썬은 멀티 스레딩을 지원하지 않는다.

    왜?

  • 바로 GIL(Global Interpreter Lock) 때문이다.

    GIL이 뭔데?

  • GIL이란 Global Interpreter Lock의 약자로 파이썬 인터프리터가 한 스레드만 하나의 바이트코드를 실행 시킬 수 있도록 해주는 Lock입니다.
    하나의 스레드에 모든 자원을 허락하고 그 후에는 Lock을 걸어 다른 스레드는 실행할 수 없게 막아버리는 것이죠.
    출처

    GIL은 왜 생긴거야?

    • GIL은 처음 파이썬에 쓰레드 구현이 들어갈 때 같이 생긴 이후로, 지금까지 계속 내려오고 있습니다. 파이썬 구현은 상당 부분이 라이브러리 전역 변수에 의존하고 있고, 여기 저기 객체 구조에 따라서 참조를 따라서 마구 접근을 하기 때문에, 동시에 쓰레드가 여러 개 돌아갈 때 심각한 문제가 발생할 수 있습니다. 따라서, 파이썬은 초창기부터 파이썬 라이브러리 코드가 돌아가는 중에는 전역적인 락(GIL)을 걸어서 동시에는 절대로 같이 못 돌아가도록 해서 해결하였습니다.출처

      쉽게 설명해보면

      출처
  • 여러개의 스레드가 병렬로 실행되는게 아니라 Context Switching을 반복하면서 순차적으로 실행된다.

예제 코드를 통한 설명

import time

if __name__ == "__main__":

    increased_num = 0

    start_time = time.time()
    for i in range(100000000):
        increased_num += 1

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

    print("increased_num=",end=""), print(increased_num)
    print("end of main")

--- 8.507359981536865 seconds ---
increased_num=100000000
end of main
  • 스레드를 사용하지 않고 숫자를 1씩증가 시켜서 1억으로 만드는 코드이다. 자그마치 8.5초나 걸렸다. 이제 스레드를 활용해서 시간을 단축 시켜보자
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
--- 7.301347017288208 seconds ---
shared_number=65657956
end of main
  • 1초밖에 안 줄어들었다! 아니 2개를 생성했으면 반으로 줄어야지 어떻게 1초밖에 안 줄지!

위에서 설명한대로 병렬로 실행을 하지 않았기 때문에 시간이 반으로 줄어들지 않는다.

또하나의 문제점

number = 50000000
number = 50000000
--- 7.301347017288208 seconds ---
shared_number=65657956  ------>>>>>????????
end of main
  • 또 하나의 문제점은 shared-number가 1억이 아니라 6500만 밖에 안된다는것이다!
    이유를 설명해보겠다.

스레드는 프로세스의 데이터 영역을 공유한다.

  • 자식 스레드들은 부모 프로세스의 데이터를 공유한다.
    스레드는 각자 스텍영역과 레지스터영역을 갖고 있고 나머지 자원을 공유한다.

문제는 자원을 공유하는 것이다.

  • 위에 설명한대로 스레드는 자원을 공유하기 때문에 아까 코드에서 전역변수 shared_number를 스레드들끼리 공유한다 그래서 숫자가 1억까지 안 가고 6500만까지만 올른다.

???????????? 자원을 공유하는데 왜 숫자가 1억까지 안가는거지?

스레드가 병렬로 실행되는 것도 아닌데 왜 공유를 했다고 문제가 생기지? 파이썬 멀티 스레드 아냐???

  • 이 질문이 머리속을 떠나지 않았다 동기화 문제가 생긴다는 걸 알겠는데 아니 파이썬ㅇ은 스레드가 동시에 실행 되는게 아닌데 왜 동기화 문제가 생길까 궁금했다.

문제는 Context Switching

  • 우선 컴퓨터가 변수에 1더하는 과정을 알아보자
  1. shared_number는 메모리에 적재 되어있는데 일단 읽는다.
  2. 뎃셈을 한다.
  3. 저장을 한다.
  • 즉 1을 더하려면 읽고 게산하고 저장을 해야한다.

그런데 만약 덧셈만하고 저장하기전에 컨텍스트 스위칭이 일어난다면???

위 그림 처럼 스레드1이 덧셈만 하고 Contexting Switching이 일어나면 스레드2 에서 shared_number가 음 0이 구나 하고 읽고 덧셈을하고 메모리에 저장을 하려고 하는데 또 Contexting Switching이 일어나면???

그렇다! 그림을 보면 스레드2에서 스레드1로 컨텍스트 스위칭을 하면서 스레드 1은 아까 켄텍스트 스위칭 되기전에 레지스터값1을참고해서 변수(shared_number)1에 저장하고 다시 스레드2로 켄텍스트 스위칭하면 스레드2도 직전에 레지스터값1을 참조해서 변수(shared_number)에 1을 저장한다

  • 이런 과정 때문에 아까 변수가 1억까지 안가고 6500만까지 가는 것이다.

반복하는 숫자가 작으면?

예제에서는 5천만을 반복했지만 숫자를 5만으로 줄이면 10만이 된다

number = 50000
number = 50000
--- 0.009940862655639648 seconds ---
shared_number=100000
  • 컨텍스트 스위칭을 하기전에 이미 게산을 완료했기 때문이다.

해결방안

mutex

뮤텍스란 Lock와 같은 기술로 임계영역을 가진 스레드들의 실행 시간이 서로 겹치지 않게 각각 단독으로 실행되게 하는 기술입니다. 또한 공유 리소스에 한 번에 하나의 스레드만 접근 할 수 있도록 하는 상호 배제 동시성 제어 정책을 강제하기 위해 설계 되었습니다.
출처

  • 뮤텍스를 사용해서 임계자원에 하나의 스레드만 접근할 수 있게 락을 걸어두는 것이다.
  for i in range(number):
        shared_number += 1
  • 아까 스레드 코드에서 이 반복문이 임계영역이고 shared_number += 1가 임계 자원이다.

lock

lock.acquire()
for i in range(number):
    shared_number += 1
lock.release()  
  • lock.acquire() 함수는 임계영역(반복문)에 하나의 스레드가 들어가면 확 잠가버려서 다른 스레드가 들어오지를 못한다.

  • 스레드1번의 임계영역(반복문)이 끝나면 스레드 2번의 반복문이 시작되고 끝난다.

최종 수정코드는 다음과 같다.

import threading
import time

shared_number = 0
lock = threading.Lock()

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    lock.acquire()
    print("1번 시작")
    for i in range(number):
        shared_number += 1
    print(shared_number)
    print("1번 끝")
    lock.release()    


def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)
    lock.acquire()
    print("2번 시작")
    for i in range(number):
        shared_number += 1
    print("2번 끝")    
    lock.release()

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
1번 시작
1번 끝
2번 시작
2번 끝
--- 7.250640869140625 seconds ---
shared_number=100000000
end of main