15 분 소요

이 페이지의 내용들 중 더 자세한 내용이나 생략된 내용들은 동시성 (concurrency), 프로그램과 프로세스 (program and process) (1), 프로그램과 프로세스 (2) 문서에서 더 자세히 살펴볼 수 있다.

개요

운영체제에서 실행되는 프로그램은 그것이 곧 프로세스이다. 계산기 프로그램과 메모장 프로그램, 그리고 인터넷 브라우저 프로그램을 실행시키고 있다면 이것은 곧 각각 계산기 프로세스, 메모장 프로세스, 인터넷 브라우저 프로세스 총 3개의 별개의 프로세스가 실행되고 있는 셈이다.

한 프로세스에는 여러 개의 스레드가 존재할 수 있다. 여러 개의 스레드를 이용하면 여러 작업을 마치 동시에 수행하는 것처럼 보일 수 있고, 그 덕분에 프로그램의 작업 속도를 좀 더 개선시킬 수 있다. 멀티 프로세스와 멀티 스레드를 이용하면 프로그램의 작업 부하를 분산시킬 수 있다.

멀티 프로세스

  • 멀티 프로세스는 별도의 메모리 영역을 가진다. 즉, 프로세스 A, B가 있을 때 A, B는 서로 각각의 메모리 영역을 가진다.
  • 멀티 프로세스, 즉 여러 개의 프로세스들은 서로 통신할 때 특별한 메커니즘으로만 통신 가능하다.
  • 프로세스는 각 스레드에 대해 별도의 레지스터 집합을 호출하거나 저장한다. 프로세스 간 데이터 통신용으로는 비효율적이다.
  • 파이썬에서는 멀티 프로세스 방식 구현을 위해 subprocess 모듈을 사용할 수 있다.
  • 한 프로세스는 다른 프로세스와는 독립적으로 스택(stack), 힙(heap), 코드(code), 데이터(data) 영역을 가진다.

멀티 스레드

  • 단일 프로세스 내의 멀티 스레드는 똑같은 메모리에 접근한다. 하나의 프로세스가 점유하는 메모리를 여러 스레드가 사용할 수 있다는 뜻이다.
  • 여러 스레드들은 데이터 공유를 통해 서로 통신을 할 수 있다. 이 때 한 번에 한 스레드만 메모리 영역에 접근할 수 있다. 이러한 작업들은 threading 모듈을 통해 수행할 수 있다.
  • 한 프로세스에 속한 스레드는 스택 영역을 제외하고 프로세스가 가질 수 있는 나머지 메모리 영역들을 가지고 공유할 수 있다.

파이썬에서도 스레드 메커니즘이 있으나, 진정으로 병렬 실행이 지원되는 것은 아니다. 즉, 동시에 여러 스레드가 동작하는 것은 아니다. 이는 파이썬에 존재하는 Global Interpreter Lock(GIL)이라고 하는 표준 파이썬 시스템에 내재된 lock 때문이다. 이 lock은 하나만 존재하고, 어떤 특정 스레드가 특정 자원에 접근하여 사용하고 있으면 여기에 lock을 걸어 다른 스레드들이 접근하지 못하도록 막는다. 이로 인해 완벽한 스레드의 병렬 실행을 할 수 없는 것이다. 이러한 lock이 존재하는 이유는 스레드 실행 시 오류가 발생하면 해당 오류를 발견하기 어려워서 이를 방지하여 스레드 안전을 지키기 위함이라고 한다.

그러나 프로세스의 경우, 병렬로 사용하는 것이 가능하다. 즉, 여러 프로세스들을 동시에 실행시킬 수 있다는 뜻이다.

동시성(concurrency) : 논리적으로 여러 작업이 동시에 실행되는 것처럼 보이는 것. I/O(파일 또는 네트워크 소켓 입출력) 연산의 경우 프로그램의 실행 속도를 저하시킬 수도 있다. 이럴 때 한 작업의 I/O 연산이 진행되는 동안 다른 작업을 수행시켜 시간을 최대한 활용하는 것이 동시성이다. (멀티 태스킹) 병렬성 (parallelism) : 물리적으로 여러 작업이 동시에 처리되는 것을 말한다. 병렬성은 데이터 병렬성과 작업 병렬성으로 나눌 수 있다. 데이터 병렬성은 같은 작업을 여러 개로 분산시켜 병렬 처리하는 것이다. 즉, 처리할 데이터가 너무 많을 경우 이 전체 데이터를 여러 개로 쪼개고 병렬 처리하는 것이다. 이렇게 하면 작업을 좀 더 빠르게 진행시킬 수 있다. 작업 병렬성은 서로 다른 작업을 병렬 처리하는 것이다. 즉, 다수의 독립적인 작업들을 개별적으로 처리하는 것이다.

subprocess 모듈

subprocess 모듈은 부모-자식 (parent-child) 프로세스 쌍을 생성할 때 쓰인다. 부모 프로세스는 사용자에 의해 실행되고, 이 부모 프로세스는 차례대로 다른 일을 처리하는 자식 프로세스의 인스턴스를 실행시킨다. 자식 프로세스를 사용하면 멀티 코어의 이점을 최대한 취하고, 동시성 문제를 운영 체제가 자동으로 처리하도록 한다. (자세한 내용 및 사용법은 프로그램과 프로세스 (program and process) (1) 문서 참고.

threading 모듈

여러 개의 스레드가 존재하면 스레드 간 데이터 공유의 복잡성이 증가한다. 그리고 락, 데드락을 잘 회피해야 한다. 파이썬으로 작성한 프로그램은 단 하나의 메인 스레드만 존재한다. 멀티 스레드를 사용하고자 한다면 threading 모듈을 사용한다.

내부적으로 락을 관리하려면, 즉 특정 자원에 대해 한 번에 하나의 스레드씩만 접근하도록 하려면 queue 모듈을 통해 큐를 구현하여 사용하면 된다. 그러면 모든 스레드들을 줄 지어 직렬화를 시킬 수 있고, 이를 통해 한 번에 하나의 스레드만 자원에 접근하도록 할 수 있다. (FIFO 방식) 실행중인 스레드가 존재하는 경우 프로그램은 종료되지 않는다.

다음은 여러 스레드를 생성시켜 수많은 작업들을 분산시켜 처리하는 예제이다. References [1]의 예제를 가져왔다.

import queue
import threading

q = queue.Queue()

def worker(num: int):
    """
    한 스레드가 특정 작업을 처리하는 테스트 함수.
    num: n번째 스레드
    """
    while True:
        item = q.get()  # queue 내의 아이템을 꺼낸다.
        if item is None:
            break  # 더 이상 queue 내의 아이템이 존재하지 않는다면 함수를 종료한다.
        # 작업을 처리한다.
        print(f"스레드 {num+1} : {item} 번째 작업 처리 완료")
        q.task_done()  # queue 내 아이템들에 대해 모두 작업을 수행하여 모든 작업을 끝마침.
    
if __name__ == '__main__':
    no_worker_threads = 5  # 스레드 5개 만들 예정
    threads = []
    for i in range(no_worker_threads):
        # target에는 실행 시킬 함수를, args에는 해당 함수에 대입할 인자를 튜플로 대입.
        t = threading.Thread(target=worker, args=(i,)) 
        t.start()  # 해당 스레드를 실행시킨다.
        threads.append(t)

    for item in range(20):
        # 처리할 작업 20개를 큐에 넣는다.
        q.put(item)

    # 큐 내의 모든 항목들에 대한 작업이 완료되어 큐 안이 빌 때까지 대기한다. 
    q.join()

    # 모든 작업을 처리하였으면 워커 스레드를 모두 종료시킨다. 
    for i in range(no_worker_threads):
        q.put(None)
    for t in threads:
        t.join()
스레드 1 : 0 번째 작업 처리 완료
스레드 4 : 1 번째 작업 처리 완료
스레드 3 : 3 번째 작업 처리 완료
스레드 1 : 5 번째 작업 처리 완료
스레드 4 : 6 번째 작업 처리 완료
스레드 3 : 7 번째 작업 처리 완료
스레드 1 : 8 번째 작업 처리 완료
스레드 4 : 9 번째 작업 처리 완료
스레드 3 : 10 번째 작업 처리 완료
스레드 1 : 11 번째 작업 처리 완료
스레드 4 : 12 번째 작업 처리 완료
스레드 3 : 13 번째 작업 처리 완료
스레드 1 : 14 번째 작업 처리 완료
스레드 4 : 15 번째 작업 처리 완료
스레드 3 : 16 번째 작업 처리 완료
스레드 1 : 17 번째 작업 처리 완료
스레드 4 : 18 번째 작업 처리 완료
스레드 3 : 19 번째 작업 처리 완료
스레드 5 : 4 번째 작업 처리 완료
스레드 2 : 2 번째 작업 처리 완료

스레드가 작업을 모두 완료했음에도 프로그램이 종료되지 않고 계속 실행된다면 문제가 될 수 있다. 즉, 메인 스레드의 작업이 끝났음에도 단 하나의 서브 스레드라도 계속 실행된다면 프로그램은 종료되지 않는다. 만약 메인 스레드의 작업이 끝나면 서브 스레드들이 처리해야할 작업이 남아있더라도 이를 중단하고 프로그램을 종료하게끔 하려면 서브 스레드를 데몬(daemon) 스레드로 전환하면 된다. 데몬 스레드는 메인 스레드의 작업이 끝나면 그 즉시 데몬 스레드의 작업도 종료되어 프로그램이 종료되게끔 해준다. 서브 스레드를 데몬 스레드로 설정하려면 Thread 객체의 daemon 속성을 True로 대입하거나, setDaemon() 메서드 안에 True를 대입하면 된다. (t.daemon = True 또는 t.setDaemon(True))

뮤텍스(mutex)와 세마포어(semaphore)

  • 경쟁 상태 (race condition) : 둘 이상의 스레드 (또는 프로세스)들이 공유 자원을 두고 서로 경쟁하는 것을 말함. lock처럼 어떤 제한 조건을 걸지 않으면 한 스레드가 공유 자원의 변경을 모두 마치기도 전에 다른 스레드가 간섭하여 해당 자원을 또 변경한다. 그러면 원하는 결과값과 다른 값이 나올 수도 있어 오류가 발생할 수 있다. 다음은 전역 변수를 두고 여러 스레드가 경쟁 상태에 놓이는 예제이다.

      import threading
        
      my_number = 0
        
      def inc_or_dec_num(thread_num: int):
          """
          my_number 숫자를 1 증가 시켰다가 다시 1 감소 시키는 함수.
          최종적으로, my_number = 0이어야 한다.
          그리고 함수 진행 과정 중에선 항상 1 또는 0 사이에서 왔다갔다해야한다.
          """
          global my_number
          my_number += 1
          print(f"현재 스레드: {thread_num}. 숫자가 1 증가하였습니다. 현재 숫자: {my_number}")
          my_number -= 1
          print(f"현재 스레드: {thread_num}. 숫자가 1 감소하였습니다. 현재 숫자: {my_number}")
        
      if __name__ == '__main__':
          threads = []
          for i in range(10):
              thread_inst = threading.Thread(target=inc_or_dec_num, args=(i+1,))
              thread_inst.start()
              threads.append(thread_inst)
        
          # 스레드 종료
          for one_th in threads:
              one_th.join()
    
      현재 스레드: 1. 숫자가 1 증가하였습니다. 현재 숫자: 1
      현재 스레드: 1. 숫자가 1 감소하였습니다. 현재 숫자: 0
      현재 스레드: 2. 숫자가 1 증가하였습니다. 현재 숫자: 1
      현재 스레드: 3. 숫자가 1 증가하였습니다. 현재 숫자: 2
      현재 스레드: 2. 숫자가 1 감소하였습니다. 현재 숫자: 1
      현재 스레드: 3. 숫자가 1 감소하였습니다. 현재 숫자: 1
      현재 스레드: 5. 숫자가 1 증가하였습니다. 현재 숫자: 2
      현재 스레드: 5. 숫자가 1 감소하였습니다. 현재 숫자: 3
      현재 스레드: 7. 숫자가 1 증가하였습니다. 현재 숫자: 4
      현재 스레드: 6. 숫자가 1 증가하였습니다. 현재 숫자: 3
      현재 스레드: 8. 숫자가 1 증가하였습니다. 현재 숫자: 4
      현재 스레드: 4. 숫자가 1 증가하였습니다. 현재 숫자: 2
      현재 스레드: 7. 숫자가 1 감소하였습니다. 현재 숫자: 3
      현재 스레드: 8. 숫자가 1 감소하였습니다. 현재 숫자: 2
      현재 스레드: 10. 숫자가 1 증가하였습니다. 현재 숫자: 3
      현재 스레드: 9. 숫자가 1 증가하였습니다. 현재 숫자: 3
      현재 스레드: 4. 숫자가 1 감소하였습니다. 현재 숫자: 2
      현재 스레드: 6. 숫자가 1 감소하였습니다. 현재 숫자: 2
      현재 스레드: 10. 숫자가 1 감소하였습니다. 현재 숫자: 1
      현재 스레드: 9. 숫자가 1 감소하였습니다. 현재 숫자: 0
    

    위 예제는 한 함수를 실행하면 전역변수의 값이 1로 증가하다가 다시 1 감소하여 0으로 되돌아오게 된다. 이 함수를 10개의 스레드가 실행되어 동시에 처리하려고 한다. 전역 변수의 초기값은 0으로, 원래 하나의 스레드가 해당 함수를 실행하면 0에서 1이 되었다가 다시 0이 되어야 한다. 그런데 10개의 스레드가 경쟁 상태에 놓여, 즉 여러 스레드가 동시에 공유 자원에 접근하여 해당 데이터를 변경하기 때문에 위 출력 결과에서처럼 3, 4 등 엉뚱한 숫자로 증가하기까지 한다. 이것이 경쟁 상태의 예이다.

  • 임계 구역 (critical section) : 하나의 스레드(또는 프로세스)가 공유 자원으로 접근하는 코드 영역을 말한다. 위 예제에서는 inc_or_dec_num 함수가 임계 구역이 되겠다.

이렇게, 여러 스레드의 공유 자원에 대한 동시 접근에 의해 공유 자원이 무분별하게 변경되어 원하는 결과와 다른 값을 내는 상황을 방지해야 하는데, 이를 해결하는 방법으로 뮤텍스와 세마포어가 있다.

뮤텍스(Mutex)

상호 배제(Mutual Exclusion)의 줄임말로, 공유 자원에 한 번에 하나의 스레드만 접근하도록 하는 방법이다. lock이라고도 불린다. 맨 처음 뮤텍스는 1의 값을 가진다. 이는 현재 공유 자원에 접근 가능한 스레드의 수로, 1개의 스레드가 접근 가능하다는 뜻이다. 이 상태에서 한 스레드가 접근하여 lock을 얻으면 이 스레드를 lock, 즉 잠궈버린다. 그리고 뮤텍스의 값은 0이 된다. 즉, 스레드가 들어갈 빈 자리가 현재 없다는 뜻이다. 공유 자원을 사용 중인 스레드는 작업을 모두 마치면 다시 lock을 해제해야 한다. (이 때 해당 스레드가 나가면 다시 뮤텍스의 값은 1이 된다) 그래야 다른 스레드가 해당 자원을 쓸 수 있기 때문이다. 스레드가 직접 lock을 획득하고 직접 lock을 해제해야한다는 점에서 뮤텍스는 locking 시스템이라고도 불린다.

이러한 개념을 보통 어떤 가게의 1칸 밖에 없는 화장실에 비유하곤 한다. 화장실을 공유 자원, 스레드를 화장실이 필요한 손님이라고 가정하면, 손님이 화장실을 이용하려면 점원으로부터 열쇠(lock)를 받아야 한다. 그 열쇠로 잠긴 화장실 문을 열고 안에서 잠가서 볼 일을 봐야하는 구조이다. 잠긴 화장실 문을 열 수 있는 사람도 화장실 안에 있는 사람만 가능하다. 화장실에서 볼 일을 다 보면 화장실 잠금을 해제해야 다음 사람도 쓸 수 있는 것이다.

파이썬에서는 threading 모듈의 Lock() 클래스를 통해 뮤텍스를 구현할 수 있다. 앞서 언급했듯, mutex 구현 시 스레드가 공유 자원에 접근하려면 우선 해당 스레드는 lock을 획득해야 한다. “화장실을 여는 열쇠”라고 생각하면 된다. 그리고 해당 스레드는 작업을 모두 완료하면 반드시 lock을 해제하여 다른 스레드도 접근 가능하도록 해둬야 한다. Lock() 클래스의 두 메서드를 통해 스레드가 lock을 얻거나 해제할 수 있다.

  • acquire() : 어떤 스레드가 해당 메서드 호출 시 해당 리소스에 lock 상태를 걸고, 다른 스레드에게 이 사실을 알려주는 역할을 한다. 만약 리소스가 이미 잠금 상태에 있다면, 해당 메서드를 호출한 스레드가 해당 리소스에 접근하는 걸 막는다. release() 라는 메서드 호출을 통해 리소스에 잠금 상태가 해제되어야만 다시 다른 스레드가 접근할 수 있는 상태가 된다.
  • release() : 리소스에 접근하여 작업하는 스레드가 작업을 끝마치고 잠금 상태를 풀 때 사용된다. 해당 메서드 호출 시 unlock 상태가 되며 다시 다른 스레드가 해당 리소스에 접근할 수 있게 된다. 해당 메서드는 오직 리소스가 잠금 상태일 때만 호출해야하며, 그렇지 않으면 에러가 발생한다.

다음은 앞서 살펴본 예제 1-1에 뮤텍스를 추가한 예제이다.

import threading

my_number = 0

def inc_or_dec_num(mutex: threading.Lock, thread_num: int):
    """
    my_number 숫자를 1 증가 시켰다가 다시 1 감소 시키는 함수.
    최종적으로, my_number = 0이어야 한다.
    그리고 함수 진행 과정 중에선 항상 1 또는 0 사이에서 왔다갔다해야한다.
    """
    mutex.acquire()  # 스레드는 lock을 얻어야 접근 가능.
    global my_number
    my_number += 1
    print(f"현재 스레드: {thread_num}. 숫자가 1 증가하였습니다. 현재 숫자: {my_number}")
    my_number -= 1
    print(f"현재 스레드: {thread_num}. 숫자가 1 감소하였습니다. 현재 숫자: {my_number}")
    mutex.release()  # lock을 풀어야 다른 스레드도 이용 가능함.

if __name__ == '__main__':
    threads = []
    mutex = threading.Lock()
    for i in range(10):
        thread_inst = threading.Thread(target=inc_or_dec_num, args=(mutex, i+1))
        thread_inst.start()
        threads.append(thread_inst)

    # 스레드 종료
    for one_th in threads:
        one_th.join()
현재 스레드: 1. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 1. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 2. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 2. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 3. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 3. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 4. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 4. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 5. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 5. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 6. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 6. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 7. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 7. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 8. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 8. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 9. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 9. 숫자가 1 감소하였습니다. 현재 숫자: 0
현재 스레드: 10. 숫자가 1 증가하였습니다. 현재 숫자: 1
현재 스레드: 10. 숫자가 1 감소하였습니다. 현재 숫자: 0

예제 1-1과 달리, 이번 예제의 출력결과를 보면, 스레드 1번부터 10번까지 차례대로 한 번에 한 스레드만 전역 변수에 접근하여 값을 변경시킨 것을 확인할 수 있다.

세마포어(semaphore)

세마포어도 뮤텍스와 똑같이 동시에 여러 스레드(또는 프로세스)가 공유 자원에 접근하는 것을 막는 방법이다. 뮤텍스와의 차이점은 뮤텍스는 한 번에 하나의 스레드만 접근 가능했던 것에 비해, 세마포어는 전체 스레드 중 여러 스레드들만 들어가게 할 수 있다. 즉, 세마포어는 공유 자원에 1개 이상의 스레드가 동시 접근 가능하다. (세마포어의 값을 1 이상의 값으로 설정하면 된다) 공유 자원에 접근할 수 있는 스레드의 수가 세마포어가 제한하는 수에 도달하면 그제서야 해당 공유 자원에 lock을 걸어 더 이상의 추가적인 스레드의 접근을 막는 구조이다. 따라서 스레드는 공유 자원 접근 시 lock을 얻을 필요가 없으며, lock을 소유하지 않아도 lock을 해제할 수 있다. 이는 뮤텍스와의 또 다른 차이점이다.

화장실에 비유하면 세마포어는 화장실 칸이 여러 개 있는 공용화장실이다. 굳이 누군가로부터 열쇠를 얻을 필요 없이 바로 사용하면 된다. 단지 모든 화장실 칸이 다 찼을 때는 더 이상 화장실 칸에 들어갈 수 없다.

다음은 References [1]의 예제를 참고한 예제이다.

import threading

class ThreadRoom():
    """
    세마포어 사용 시 현재 임계 영역에 존재하는 스레드 명단 기록 클래스.
    """
    def __init__(self):
        self.current_threads = []
        self.lock = threading.Lock()

    def acquire(self, name):
        """
        한 스레드가 lock을 획득한다.
        name: threading.currentThread().name
        """
        with self.lock:
            self.current_threads.append(name)
            print(f"lock 획득. 현재 임계 영역 내 전체 스레드 목록: {self.current_threads}")

    def release(self, name):
        with self.lock:
            self.current_threads.remove(name)
            print(f"lock 반환. 현재 임계 영역 내 전체 스레드 목록: {self.current_threads}")

my_number = 0
def critical_section(semaphore: threading.Semaphore, thread_room: ThreadRoom):
    global my_number
    with semaphore:
        name = threading.current_thread().name
        thread_room.acquire(name)
        my_number += 1
        print(f"현재 변수값: {my_number}")
        my_number -= 1
        print(f"현재 변수값: {my_number}")
        thread_room.release(name)

if __name__ == '__main__':
    threads = []
    thread_room = ThreadRoom()
    semaphore = threading.Semaphore(3)  # 최대 3개의 스레드까지 접근 허용
    for i in range(5):
        t = threading.Thread(
            target=critical_section, 
            name=f"스레드 {str(i+1)}",
            args=(semaphore, thread_room)
        )
        t.start()
        threads.append(t)
    for t in threads:
        t.join()
lock 획득. 현재 임계 영역  전체 스레드 목록: ['스레드 1']
현재 변수값: 1
현재 변수값: 0
lock 획득. 현재 임계 영역  전체 스레드 목록: ['스레드 1', '스레드 2']
현재 변수값: 1
현재 변수값: 0
lock 획득. 현재 임계 영역  전체 스레드 목록: ['스레드 1', '스레드 2', '스레드 3']
현재 변수값: 1
lock 반환. 현재 임계 영역  전체 스레드 목록: ['스레드 2', '스레드 3']
현재 변수값: 0
lock 반환. 현재 임계 영역  전체 스레드 목록: ['스레드 3']
lock 획득. 현재 임계 영역  전체 스레드 목록: ['스레드 3', '스레드 4']
현재 변수값: 1
현재 변수값: 0
lock 반환. 현재 임계 영역  전체 스레드 목록: ['스레드 4']
lock 획득. 현재 임계 영역  전체 스레드 목록: ['스레드 4', '스레드 5']
현재 변수값: 1
현재 변수값: 0
lock 반환. 현재 임계 영역  전체 스레드 목록: ['스레드 5']
lock 반환. 현재 임계 영역  전체 스레드 목록: []

뮤텍스와 세마포어의 차이점 정리

  • 뮤텍스는 동기화 대상이 오직 1개일 때만, 세마포어는 동기화 대상이 1개 이상일 때 사용.
  • 세마포어는 값을 1로 설정하면 이진 세마포어(binary semaphore)인 뮤텍스처럼 사용할 수 있으나, 뮤텍스는 애초에 최대값이 1밖에 안되므로 세마포어가 될 수 없다. 참고로 2 이상의 값을 가지는 세마포어는 Counting semaphore 라고 부른다.

데드락과 스핀락

데드락

교착 상태로 해석되는 데드락(deadlock)은 둘 이상의 프로세스 또는 스레드가 서로의 작업이 끝나기만을 기다려서 아무런 작업도 완료할 수 없는 상황을 일컫는다. 락을 순서대로 획득하도록 한다면 데드락을 막을 수 있다(아마 큐와 같은 기술을 사용하면 되는 것 같다). 그러나 이런 방법은 일반적인 방법이지 좀 더 정교한 문제도 해결할 수 있는 방법은 아니다.

데드락의 발생 조건

다음의 네 가지 조건을 모두 충족하면 데드락이 발생한다. 그래서 넷 중 하나라도 방지할 수 있으면 데드락을 해결할 수 있다.

  1. 상호 배제 (mutual exclusion) : 한 번에 하나의 프로세스 혹은 스레드만 자원을 사용 가능하게 하는 것.
  2. 점유와 대기 (hold and wait) : 한 프로세스 혹은 스레드가 자원을 소유하고 있는 상황에서, 다른 프로세스(또는 스레드)가 해당 자원의 반납을 기다리는 경우.
  3. 비선점 (no preemption) : 다른 프로세스 혹은 스레드가 점유하고 있는 자원을 강제로 뺏어오지 못하게 하는 것.
  4. 순환 대기 (circular wait) : 여러 프로세스(또는 스레드)들이 서로 꼬리를 물듯이 다른 프로세스(또는 스레드)의 자원 반납을 대기하는 상태. 예를 들어, 프로세스 A, B, C가 존재하는 경우, A는 B가 점유한 자원을, B는 C가 점유하고 있는 자원을, C는 A가 점유하고 있는 자원을 기다리는 경우가 해당한다.

스핀락

스핀락(spinlock)은 말 그대로 돌고 있는 락을 의미하는 것으로, 임계 구역에 진입이 불가능할 경우(이미 lock을 획득하여 공유 자원을 사용중인 스레드가 오랫동안 lock을 안 풀고 계속 제자리에 있는 경우) 반복문을 돌면서 진입이 가능할 때까지 진입을 시도하는 락이다. 스핀락은 바쁜 대기(busy waiting)의 한 종류이다.

바쁜 대기는 어떤 공유 자원에 대해 둘 이상의 프로세스 혹은 스레드가 해당 자원의 접근 권한을 획득하기 위해 대기하는 현상이다.

문맥 교환(context switch)은 어떤 하나의 프로세스가 CPU를 사용 중인 상태에서 다른 프로세스가 CPU를 사용해야 하는 경우, 기존에 CPU를 사용 중인 프로세스의 상태(문맥)를 먼저 저장하고, 새로운 프로세스의 문맥을 들여와 CPU를 사용할 수 있게 하는 작업을 말한다. 이렇게 문맥을 교환하는 동안에는 어떠한 작업도 진행시킬 수 없다.

임계구역에 진입이 불가능할 경우, 임계구역을 차지하고 있는 스레드를 걷어내고 새로운 스레드가 진입 가능하게끔 하는 문맥 교환의 방식을 사용할 수도 있지만, 스핀락을 사용하면 문맥 교환 작업에 따른 시간을 단축시켜 더 빠르게 임계 구역에 진입이 가능하다. lock의 획득과 반환 과정이 아주 짧아서 공유 자원에 대한 작업 시간이 매우 빠르거나, 임계 구역이 작을 경우에 스핀락을 쓰면 좋다.

그러나 스핀락은 임계 구역에 진입할 수 있는 시간이 짧을 경우에만 유용하다. 스핀락은 반복문을 무한정 돌면서 최대한 다른 스레드들이 임계 구역으로 접근하는 것을 막아 가장 먼저 임계 구역에 들어가려고 하는 방식이다(다 비켜!! 내가 먼저 들어갈꺼야!!!). 그래서 만약 임계 구역에 진입하는데에 너무 오래 걸린다면 결국 다른 스레드들도 기다려야 하는 것이기에 오히려 CPU를 낭비하게 된다.


Reference

[1] 지은이: 미아 스타인, 옮긴이: 최길우, “파이썬 자료구조와 알고리즘”, (2019, 한빛미디어)

[2]

Python 강좌 : 제 31강 - 쓰레드

[3]

[Python] 멀티 스레드 - 2 (데몬 스레드)

[4]

02) 파이썬 스레드

[5]

Thread lock(critical section) in Python

[6]

경쟁 상태와 임계 영역(Critical section)

[7]

(10) 경쟁 상태 ( Race Condition )

[8]

[운영체제] Race Condition과 예방할 방법(세마포어, 뮤텍스)

[9]

프로세스 동기화(Process Synchronization) [파이썬(Python)]

[10]

[운영체제] Mutex 뮤텍스와 Semaphore 세마포어의 차이

[11]

[OS] 세마포어(Semaphore) vs 뮤텍스(Mutex) 차이

[12]

스핀락

[13]

[운영체제] 스핀락(Spin Lock)이란?

[14]

문맥 교환

[15]

OS - Context Switch(컨텍스트 스위치)가 무엇인가?

This content is licensed under CC BY-NC 4.0

댓글남기기