-목차-
1. 프로세스 동기화 (Process Synchronization)란?
- 정의 : 협력하는 프로세스 사이에서 실행 순서 규칙을 정하여 공유 자원의 일관성을 보장하는 것
- 프로세스가 서로 협력하며 공유 자원을 사용하는 상황에서, 경쟁 조건이 발생하면 공유 자원을 신뢰할 수 없게 만들 수 있다. 이를 방지하기 위해 프로세스들이 공유 자원을 사용할 때 특별한 규칙을 만드는 것이 프로세스 동기화이다.
임계 구역 (Critical Section) : 여러 프로세스(또는 스레드)가 자원을 공유하는 상황에서, 하나의 프로세스(스레드)만 접근할 수 있도록 제한해둔 코드 영역
2. 임계구역(Critical section) 문제
임계 구역은 여러 개의 스레드가 수행되는 시스템에서 각 프로세스(스레드)들이공유하는 데이터(변수, 테이블, 파일 등)를 변경하는 코드 영역을 말한다. 이는 동기화에서 중요한 문제 중 하나이다.
임계 구역을 해결하기 위해서는 3가지 조건이 만족해야 한다.
- Mutual exclusion(상호 배제): 오직 한 스레드만이 진입 가능하다. 한 스레드가 임계 구역에서 수행 중인 상태에서는 다른 쓰레드는 절대 이 구역에 접근할 수 없다.
- Progress(진행): 한 임계구역에 접근하는 스레드를 결정하는 것은 유한 시간 이내에 이루어져야 한다.
- Bounded waiting(유한 대기): 임계 구역으로 진입하기 위해 대기하는 모든 쓰레드는 유한 시간 이내에 해당 임계구역으로 진입할 수 있어야 한다.
3. 임계 구역과 동기화의 필요성
먼저 동기화의 필요성을 확인하기 위해 스레드 예시를 통해 확인해보도록 하자.
구현 코드 : thread.py
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__":
start_time = time.time()
t1 = threading.Thread( target= thread_1, args=(50000000,) )
t1.start()
t2 = threading.Thread( target= thread_2, args=(50000000,) )
t2.start()
threads = [t1, 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")
참고
하나의 스레드는 프로그램 속에서 하나의 실행 흐름이다. 그렇다면 멀티스레딩(multithreading)은 스레드 여러 개를 사용한다는 말이고, 이는 한 프로세스 안에서 여러 스레드가 자원을 공유하여 처리한다는 것을 뜻한다. 결국 스레드를 여러개 사용하면 실행 속도도 빨라지겠구나 생각하겠지만, 전혀 그렇지 않다. 오히려 느려질 수도 있다.
왜 이와 같은 비상식적인 일이 발생한 것일까? 이는 파이썬의 GIL(Global Interpreter Lock)에 기인한 것으로, 자세한 내용은 이곳을 참조하고 여기서는 간단히 설명하겠다. 파이썬에서는 GIL이 여러 개의 스레드가 동시에 실행되는 것을 막아놓았다. 그렇기 때문에 여러 스레드를 동시에 실행시키도록 코드를 짜도, 같은 시간대에 실행되는 스레드는 언제나 하나이다.
구현을 위해 필요한 메서드만 간단히 설명하자면
- import threading : 스레딩 모듈을 임포트 해야 멀티 스레드를 사용할 수 있다.
- threading.Thread(target=호출할 객체, args=(인자), kwargs=(키워드 인자)) : 스레드를 호출하여 어떤 작업을 시킬지 정한다. 여기서 constructor는 반드시 keyword argument 형식으로 호출해야 한다.
- thread.start() : 이름처럼 스레드에게 작업을 시작하라고 명령하는 메서드이다. 스레드 객체 안에서 반드시 한 번만 실행해야 하고, 그 이상 실행할 시 RuntimeError가 발생한다.
- thread.join() : 스레드가 작업을 종료할 때까지 기다리게 하는 메소드이다. 그리고 이 메서드가 실행된 스레드가 종료되기 전까지는 호출되는 것을 막는다.
코드의 실행 결과는 아래와 같다.
number=50,000,000을 더한 100,000,000이 나와야 하는데 예상과는 다른 결과가 출력된 것을 확인할 수 있다. 위의 코드는 스레드 동기화가 이루어지지 않아 발생한 오류이다.
멀티스레딩을 할 때 각 스레드는 다른 스레드가 어디서 무엇을 하는지 전혀 알지 못한다. 그리고 각 스레드 간에는 메모리가 겹치는 영역이 존재하기 때문에, 공유 리소스에 동시에 접근하는 경우가 발생한다. 동시에 접근하게 되면 아래와 같이 사용자가 의도하지 않은 결과가 나타나게 되고, 그것이 위에서 나타난 결과이다.
이러한 상황을 방지하기 위해서 스레드를 동기화(Syncronization)하여 한 스레드가 이미 사용하고 있는 자원을 다른 스레드는 사용하지 못하게 하는 것이 필요하다. 이제 동기화 방법에 대해 알아보도록 하자.
4. Lock(MutEx)
Lock은 다른 언어에서는 MutEx(Mutual Exclusion=상호 배제)라고도 부른다. 말 그대로 둘이 같은 공간에 있으면 안 된다는 뜻이다. 동시에 공유 자원에 접근하는 것을 막기 위해 Critical section 에 진입하는 프로세스는 Lock 을 획득하고 Critical section 을 빠져나올 때, Lock 을 방출함으로써 동시에 접근이 되지 않도록 한다.
파이썬에서는 이를 Lock이라고 정의해, 스레드가 공유자원을 사용하고 있으면 그곳의 lock을 얻게 되고, 스레드는 lock이 반환되지 않는 이상 그 자원을 사용하지 못한다.
Lock을 사용하기 위해서는, lock=threading.Lock()으로 Lock 객체를 생성하고, 작업이 시작하는 곳에서 lock.acquire()를 실행해 Lock을 획득하고, 작업이 끝나는 곳에서 lock.release()를 실행해 Lock을 해제한다. 아래 코드를 보자.
구현 코드 : lock.py
import threading
import time
shared_number = 0
lock=threading.Lock() # Lock 객체 실행
def thread_1(number):
global shared_number
print("number = ", end=""), print(number)
lock.acquire() # 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() # Lock 획득
for i in range(number):
shared_number += 1
lock.release() # Lock 해제
if __name__ == "__main__":
start_time = time.time()
t1 = threading.Thread( target= thread_1, args=(50000000,) )
t1.start()
t2 = threading.Thread( target= thread_2, args=(50000000,) )
t2.start()
threads = [t1, 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")
이 함수를 실행하면 shared_number가 100,000,000이 되어 각 스레드가 동시에 실행되지 않고 접근한 순서대로 실행되었음을 알 수 있다.
Lock의 한계
멀티 프로세싱 환경에서 싱글 CPU의 경우 유용하지 않다. 만약 하나의 프로세스가 Lock을 가지고 있고, 그 프로세스가 Lock을 풀어주기 위해서는 싱글 CPU 시스템에서 필연적으로 Context switch가 일어나야 하기 때문이다.
busy waiting 문제
Lock은 기본적으로 무한 루프를 돌면서 Lock을 기다리므로 하나의 프로세스(스레드)가 Lock을 오랫동안 가지고 있다면, 다른 프로세스는 계속 기다려야 하는 상황이 발생한다.
5. 세마포어(Semaphore)
Critical section 문제를 해결하기 위한 동기화 도구로서 임의의 S변수 하나에 Ready queue 하나가 할당된다. 즉 대기실이 존재한다.
Lock과 가장 큰 차이점은 Ready queue가 존재한다는 점이다. Lock에서는 루프를 통해 공유자원을 기다렸다면, 세마포어에서는 Ready queue라는 공간(대기실)에서 기다리면서, 공유자원을 할당받는다.
파이썬에서는 세마포어(Semaphore) 사용을 위한 공유 메모리(Shared Memory)를 지원한다.
공유 메모리를 객체를 생성하기 위해서는 multiprocessing.shared_memory.SharedMemory(name=None, create=False, size=0) SharedMemory 메서드를 실행하는데, 새로 생성할 때와 기존 메모리에 붙일 때의 인자가 약간 다르다.
새로 공유 메모리를 생성할 때는 create=True와 size=크기를 지정해 줄 필요가 있고, 기존 메모리에 붙인다면 기존 메모리의 name을 명시해 주어야 한다.
또한 공유 메모리를 사용할 때는 반드시 close()와 unlink()를 실행해야 한다. 모든 개체들은 개체를 사용하고 나면 close()를 통해 공유 메모리로의 접근을 차단해야 한다. 다만 close()는 공유 메모리를 완전히 없애는 것은 아니므로, 사용한 공유 메모리를 완전히 삭제하기 위해서는 unlink()를 실행해 주어야 한다.
두 프로세스에서 50,000,000씩 더해 100,000,000이 되게 하는 코드를 세마포어와 공유 메모리로 구현하면 아래와 같다.
구현 코드 : Semaphore_shared_memory.py
from multiprocessing import Process, Semaphore, shared_memory
import numpy as np
import time
def worker(id, number, a, shm, sema):
increased_number = 0
for i in range(number):
increased_number += 1
# 세마포어 획득
sema.acquire()
# 앞서 생성해 놓은 공유 메모리 블록을 가져와 사용
existing_shm=shared_memory.SharedMemory(name=shm)
# 공유 메모리의 버퍼를 numpy 배열로 변환
b=np.ndarray(a.shape, dtype=a.dtype, buffer=existing_shm.buf)
# 각각의 프로세스에서 연산한 값을 합해서 numpy 배열에 저장
b[0] += increased_number
# 세마포어 해제
sema.release()
if __name__ == "__main__":
# 세마포어 생성
sema=Semaphore()
start_time = time.time()
# 숫자를 저장할 numpy 배열 생성
a=np.array([0])
# nbyes=저장된 만큼의 크기
# 공유메모리를 생성
shm=shared_memory.SharedMemory(create=True, size=a.nbytes)
# 공유 메모리로 동작하는 numpy 배열 생성 / 공유 메모리의 버퍼를 numpy 배열로 변환
c=np.ndarray(a.shape, dtype=a.dtype, buffer=shm.buf)
# 프로세스 2개 생성
P1 = Process(target=worker, args=(1, 50000000, a, shm.name, sema))
P2 = Process(target=worker, args=(2, 50000000, a, shm.name, sema))
PS = [P1, P2]
# 프로세스 시작
for P in PS:
P.start()
# 프로세스가 종료될 때까지 기다린다.
for P in PS:
P.join()
print("--- %s seconds ---" % (time.time() - start_time))
print("total_number=",end=""), print(c[0])
# 공유 메모리 사용종료
shm.close()
# 공유 메모리 블록 삭제
shm.unlink()
print("end of main")
실행 결과를 멀티스레딩과 비교해 보면 시간이 반 이상 줄어든 것을 볼 수 있다. 공유 메모리를 생성해 각각의 프로세스마다 연산한 결과를 공유 메모리에 저장하는데, 공유 메모리에 접근하는 것을 세마포어를 이용해 제한을 두어 간섭이 일어나지 않게 한 것이다. 이러한 Ready queue를 사용하는 세마포어는 교착상태를 일으킬 가능성이 있다.
Deadlock(교착상태)
- 둘 이상의 프로세스가 Critical section 진입을 무한정 기다리고 있고, Critical section에서 실행되는 프로세스는 진입 대기 중인 프로세스가 실행되야만 빠져나올 수 있는 상황을 지칭한다.
참고 자료
https://velog.io/@jiffydev/TIL-7.-Threading-Multiprocessing
https://daphne-dev.github.io/2020/09/19/python-multiproccessing/
https://sangcho.tistory.com/entry/프로세스동기화및상호배제
'운영체제' 카테고리의 다른 글
가상 메모리(Virtual Memory) (0) | 2022.02.04 |
---|---|
메모리 관리 전략 (0) | 2022.02.03 |
Blocking, Non-blocking, Sync, Async 의 차이 (0) | 2022.02.02 |
스케줄러 (0) | 2022.01.27 |
운영체제란 무엇인가? (0) | 2022.01.27 |
댓글