스레드
스레드의 개념과 파이썬에서 스레드 사용및 문제점에 대해 알아보자
🧶 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은 왜 생긴거야?
- 여러개의 스레드가 병렬로 실행되는게 아니라 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더하는 과정을 알아보자
- shared_number는 메모리에 적재 되어있는데 일단 읽는다.
- 뎃셈을 한다.
- 저장을 한다.
- 즉 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