Thread

폐쇄맨·2020년 7월 26일
1

WeCode

목록 보기
11/22

서론

대학교에서 스레드에 대해 배울 때 너무 재미가 없어서 수업 중에 뒷자리에 앉아 틀린그림찾기를 하던 시절이 있었다. 별로 후회되지는 않는다 ㅋㅋㅋㅋ 🤣. 어차피 돌아가도 똑같이 할 것 같다. 어쨌든 다시 등장했으니, 이제 나이값을 하기 위해 열심히 공부해보도록 하겠다.

동호님이 빌려주신 책 "코딩을 지탱하는 기술"과 각종 블로그, 문서 읽고 대강 요약한 것이다. 워낙 재미없는 내용이니 마찬가지로 내 글도 그러할 것이라고 생각한다.

병행 처리

병행 처리 라고 하니 뭔가 엄청 어려워 보인다 😰. 그냥 게임하면서 음악듣는것을 생각하면 쉽다. 이러한 병행 처리를 위해 프로세스나 스레드 (thread) 등의 개념이 만들어졌다. 지금 부터 이들 주제에 대해 이야기 해본다.

병행 처리의 비밀

"CPU가 하나인데 어떻게 복수의 처리를 동시에 할 수 있을까?" 하는 궁금증이 들지는 않겠지만 들었다고 쳐보자 ㅋㅋㅋㅋ 😂. 멀티코어 시대이지만 그냥 하나의 한 개의 코어만 있다고 가정해보자.

실제로는 한 번에 하나의 처리만 실행이된다. 그것이 매우 빨라서 사람이 눈치채지 못하는 것 뿐이다. 여러 프로그램의 실행을 잘게 분할해서 빠르게 바꿔치기를 하면서 실행시킨다.

처리를 변경하는 두 가지 방법

프로그램을 교대하면서 처리한다면 언제 교대시켜야 할까? 그것에 관한 얘기를 해본다.

1. 협력적 멀티태스크

일단 첫번째로, 처리가 마무리되는 시점에 자발적으로 교대를 하는 방법이다. 이 방법으로 구현된 멀티태스크를 협력적 멀티태스크 라고 한다.

그런데 어떤 하나의 처리가 마무리되지 않으면 기다리는 처리는 무한정 기다려야하는 크나큰 단점 이있다.

Windows 3.1이나 Mac OS 9가 프로그램 버그로 교대가 불가능한 상황이 오면 다른 프로그램에게 처리 기회를 주지 않고, 전부 묶어서 백업을 했다.

2. 선점적 멀티태스크

다른 방법은 "일정 시간에 교대" 하는 것이다. 이 경우는 태스크 스케쥴러가 존재하여 일정 시간이 지난 프로그램은 중단시키고 다른 프로그램의 처리를 실행한다. 이 방법으로 구현된 멀티태스크를 선점적 (preemptive) 멀티태스크 라고 부른다.

Windows 95, Mac OS X 이후 버전, UNIX, Linux 등은 운영체제 자체가 이 방법으로 복수 프로그램을 병행 처리한다.

경합 상태 방지법

선점적 멀티태스크는 사용자 입장에서 좋지만 프로그래머의 입장은 다르다. 태스크 스케쥴러가 교대를 언제 시켜줄지 모르는 상황에서 프로그램을 작성하는 것은 어려운 일이다.

Repl.it에 올라왔던 코드를 본다.

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
--- 6.68903112411499 seconds ---
shared_number=64162823
end of main

결과값이 분명 1억이 나와야함에도 한참 못미치는 숫자가 나왔다. 이런 상태를 "경합 상태 (Race condition)" 또는 "스레드 세이프 (Thread Safe)하지 못하다" 라고 표현한다.

경합 상태의 3가지 조건

위의 예제와 같은 경합 상태가 발생하기 위해서는 3가지 조건을 만족해야한다.

  1. 2가지 처리가 변수를 공유한다.
  2. 적어도 하나의 처리가 그 변수를 변경한다.
  3. 한쪽의 처리가 한 단락 마무리 되기 전에, 다른 쪽의 처리가 끼어든다.

반대로 말하면, 이 3가지 조건 중 하나라도 제거할 수 있다면 병행 실행 시에도 안정된 프로그램을 만들 수 있다. 이것에 대해 이야기 해본다.

프로세스와 액터 모델

처음부터 아무것도 공유하지 않으면 경합 상태를 신경쓰지 않아도 된다.

프로세스는 메모리를 공유하지 않는다

UNIX에서 실행 중의 프로그램을 프로세스 라고 부른다. 프로세스들은 메모리를 공유하지 않는다. 여러 프로세스가 같은 파일이나 데이터베이스를 동시에 오픈하는 등의 상태만 주의하면된다.

UNIX는 "하나의 프로세스 안에서 실행되는 처리는 하나" 라는 구조였다. 그래서 병행 처리를 위해 복수의 프로세스를 구동해야 했다. 그러나 이것은 너무 엄격한 구조였다. 그래서 스레드라는 개념이 생겨났고 현재도 스레드를 사용해서 공유 메모리를 어떻게 다뤄야하는지 고심하며 프로그램을 만든다.

액터 모델

병행해서 동작하는 복수의 처리가 정보를 교환하는 방법으로, "메모리를 공유한다" 가 아닌 "메시지를 보낸다" 는 방식이다. 액터는 수신하는 메세지에 대해 응답 (책임)을 가지고 있는 계산 개체이며 동시적으로 수행된다. 조금 간단하게 말하자면, 자신의 스레드와 큐를 가지고 있다고 생각하면된다.

Twitter, Facebook 등 많은 사용자 메시지를 취급하는 서비스에서 이 액터 모델이 적합한 처리가 많다 (여기서 말하는 메시지와 윗문단의 메시지는 다른거다).

const, val, Immutable

경합 상태 조건 2번을 반대로 생각해보면, "메모리를 공유해도 변경하지 않으면 문제가 없다" 라는 것을 알 수 있다.

언어 자체에서 const, val 같은 상수 키워드를 제공할 수 도 있다. 그리고 Immutable 디자인 패턴을 사용할 수도 있다.

클래스에 private 필드를 만들어서 그것을 읽는 getter 메서드는 만들지만, 변경을 위한 setter 메서드는 만들지 않는 디자인 패턴을 Immutable 패턴 이라고 한다.

끼어들지 않는다

경합 상태 조건 3번을 방지하기 위해서 어떻게 하면 좋을지 얘기해 보자.

파이버, 코루틴, 그린 스레드 (협력적 스레드)

그 방법 중 하나가, 파이버 (fiber), 코루틴 (coroutine), 그린 스레드 (Green thread) 등으로 불리는 기법을 사용하는 것이다. 스레드가 선점을 하려는 것이 문제의 원인이기 때문에 협력적 스레드를 만들면 된다는 생각이다. Ruby의 Fiber 클래스나 Python, JavaScript의 제너레이터 (Generator)가 그 예다.

물론 협력적 멀티태스크이기 때문에 어떤 스레드가 CPU를 독점하면 다른 스레드의 처리가 멈춘다. 어디까지나 각 스레드가 협력적으로 최적의 순간을 맞춘다는 사실을 전제로 하고 있다.

락, 뮤텍스, 세마포어

또 다른 방법은 "지금 끼어들지 마세요" 라는 표식을 공유하는 것이다. 예를 들어, 어떤 메모리 값이 0이 아니면 이것은 "다른 스레드가 끼어들면 곤란한 처리를 하고 있다" 라고 정하는 것 이다.

이 방법에는 락 (Lock), 뮤텍스 (Mutex), 세마포어 (Semaphore) 등 다양한 방법이 있지만, 핵심 개념은 "On Air" 표식과 같다. 하지만 실제는 이런 표식을 붙여둘 뿐, 표식을 확인하지 않는 스레드가 있으면 아무 소용이 없다.

이것을 구현하는 것도 쉽지 않다. 표식의 값을 확인하는 도중에 다른 스레드가 끼어들 수 있기 때문이다. 그래서 기계어의 값 확인과 변경을 한 번에 실행하는 명령을 사용해야 한다.

락의 문제점과 해결책

락의 문제점

아직도 문제가 존재한다...

교착 상태의 발생

공유된 X와 Y를 변경하는 처리 A와 처리 B가 있다고 하자. A가 "X를 락시키고, Y를 락시킨다"는 순서로 락을 걸어두고, B가 "Y를 락시키고, X를 락시킨다"는 순서로 락을 걸었을 때 문제가 발생한다. A와 B 둘 다 상대방의 락이 풀리기를 기다리게 되는 것이다.

이것을 교착 상태 (Deadlock) 라고 부른다. 그렇기 때문에 프로그래머는 "무엇에 락을 걸어야하는가" 뿐만 아니라 "어떤 순서로 락을 걸어야하는가" 에도 유의해야 한다.

합성할 수 없다

아래의 그림을 보자 (귀찮아서 대충찍었다 😂😂).

스레드 세이프 라이브러리에서는 락 제어를 프로그래머가 챙기지 않아도 되도록 내부에서 락을 사용해 "꺼내는 처리"나 "추가하는 처리"가 끼어들지 못하도록 한다.

하지만 이 락으로는 위 코드처럼 "어중간한 상태" 일 때 끼어드는 것을 막을 수는 없다. 결국 프로그래머가 "꺼내는 처리"와 "추가하는 처리"를 묶는 새로운 락을 만들어야한다.

그래서 이것은 너무 신경쓸 것이 많다. 더 좋은 방법이 없을까?

트랜잭션 메모리

위의 문제를 해결하려 하는 것이 트랜잭션 메모리 (Transaction Memory) 라는 접근법이다. 데이터베이스의 트랜잭션 기법을 메모리에 적용한 것이다. 개념은 "일단 실험적으로 해보고, 실패하면 처음부터 다시 고쳐서 하고, 성공하면 변경을 공유한다" 이다. 직접 X나 Y를 변경하는 것이 아니라, 일시적으로 별도 버전을 만들어서 그것을 변경하고, 하나의 묶음 처리가 끝나면 반영하는 것이 포인트다.

아래는 트랜잭션 메모리의 예시 그림이다. 별도의 버전을 만들어 변경하기 때문에 중간에 끼어드는 처리가 있어도 괜찮다.

쓰는 처리가 끼어들면, 기존에 일시적으로 만들어진 버전은 버리고 다시 시작한다. 이렇게 락을 걸지 않아도 문제 없이 병행 처리가 가능하다. 하지만 쓰는 처리의 빈도가 높을 때는 성능 저하로 이어질 수 있다.

Assignments

1

파이썬 공식문서를 공부하고 자유롭게 포스팅하는 것이 과제 1번이다.

repl.it 코드를 분석해 보겠다. 코드 블록에 라인 넘버를 추가하는 법을 몰라서 (아마도 안되는 것 같다) 주석을 달아 분석을 했다.

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()
    
    """
    Thread 클래스는 별도의 제어 스레드에서 실행되는 활동을 나타낸다.
    target은 run() 메서드에 의해 호출될 callable 객체이다. 
    여기서는 함수 thread_1, thread_2의 레퍼런스를 넘긴다.
    args는 target 호출을 할 때 필요한 인자 튜플이다.
    나머지 파라미터로는 name, group 등이 있다.
    """
    t1 = threading.Thread( target= thread_1, args=(50000000,) )
    """
    start()는 스레드 활동의 시작을 나타낸다.
    스레드 객체 당 최대 한 번 호출이 가능하다.
    객체의 run() 메서드가 별도의 제어 스레드에서 호출되도록 한다.
    이 메서드는 같은 스레드 객체에 두 번 이상 호출되면,
    RuntimeError를 발생시킨다.
    """
    t1.start()			
    threads.append(t1)

    t2 = threading.Thread( target= thread_2, args=(50000000,) )    
    t2.start()
    threads.append(t2)

    """
    join()은 스레드가 종료할 때까지 기다리는 메서드이다.
    join() 메서드가 호출된 스레드가 처리되지 않은 예외를 통해 종료하거나,
    timeout이 발생할 때까지 다른 스레드를 블록한다.
    timeout 인자가 없거나 None이면 스레드가 종료될 때까지
    작업이 블록된다.
    
    즉, 아래 print()들은 두 개의 스레드가 끝날때 까지 실행되지 않는다.
    """
    for t in threads:
        t.join()

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

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

2

shared_number 가 1억이 되지 않는 이유를 설명하는 것이 과제 2번이다.

t1.start()t2.start() 를 호출했으니 각 스레드가 shared_number에 값을 쓰려고 노력할 것 이다. 그러면 t1 스레드가 shared_number에 값을 채 쓰기도 전에 t2 스레드가 값을 쓴다던지 하는 현상이 발생한다. 위에서 말한 선점적 멀티태스크의 단점이라고 할 수 있다.

3

위의 코드를 수정하여 shared_number의 값을 1억으로 맞추는 것이 과제 3번이다. 힌트는 조건변수, 뮤텍스가 주어졌다.

Mutex (상호 배제)

먼저 서로 다른 두 프로세스, 혹은 스레드 등의 처리 단위가 같이 접근해서 안되는 공유 영역을 임계 구역 (Critical Section) 이라 한다. 보호되지 않는 임계 구역에 두 처리 단위가 동시에 접근할 때 발생하는 문제를 "임계 구역 문제" 라고 한다.

임계(臨界): 사물이 어떠한 기준에 의하여 분간되는 한계

두 처리 단위가 임계 구역에 동시에 접근하지 못하도록 막는 기법이 상호 배제 (Mutual Exclusion) 이며 줄여서 Mutex라고 한다. 뮤텍스는 최소한 아래 두 가지 연산을 지원해야 한다.

  • lock: 임계 구역에 들어갈 권한을 얻어온다. 만일 다른 프로세스/스레드가 먼저 선점하고 있다면 종료할 때 까지 대기한다.
  • unlock: 현재의 임계 구역을 모두 사용했음을 알린다. 대기중인 다른 프로세스/스레드가 임계 구역에 진입할 수 있다.

뮤텍스를 파이썬에서 Lock 객체로 지원한다.

Condition 객체

조건 변수 (Condition Variable) 는 그냥 단어 그대로 조건 변수이다. 특정 조건이 만족될 때 까지 현재 Thread를 Blocking 할 수 있다. 이것은 파이썬에서 Condition 객체의 형태로 제공한다. lock 인자가 None이 아니면, Lock이나 RLock 객체여야 하며, 하부 록으로 사용된다. 그렇지 않으면 새로운 RLock 객체가 생성된다.

Condition 객체의 대표적인 다섯가지 메서드를 알아본다.

  • acquire(*args)
    하부 록을 획득한다. 이 메서드는 하부 록에서 해당 메서드를 호출한다. 반환 값은 그 메서드가 반환하는 것이다.

  • release()
    하부 록을 해제합니다. 이 메서드는 하부 록에서 해당 메서드를 호출한다.

  • wait(timeout=one)
    통지되거나 시간제한이 만료될 때 까지 기다린다. 이 메서드가 호출될 때 호출하는 스레드가 록을 획득하지 않았으면, RuntimeError가 발생한다.

    이 메서드에서 하부 록을 해제한 다음, 같은 조건 변수에 대한 다른 스레드에서의 notify()나 notify_all() 호출에 의해 깨어날 때까지 또는 timeout이 발생할 때까지 블록 한다. 일단 깨어나거나 시간제한이 만료되면, 록을 다시 획득하고 반환한다.

    timeout 인자가 None이 아니면, timeout 값을 부동 소수점으로 second 단위로 정해야 한다.

    주어진 timeout이 만료되지 않는 한 반환 값은 True이며, 만료되면 False이다.

  • notify(n=1)
    기본적으로, 이 조건에서 대기 중인 하나의 스레드를 깨운다. 이 메서드가 호출될 때 호출하는 스레드가 잠금을 획득하지 않았으면 RuntimeError가 발생한다.

    이 메서드는 조건 변수를 기다리는 스레드를 최대 n개 깨운다. 기다리는 스레드가 없으면 아무일도 하지 않는다.

    적어도 n 스레드가 대기 중이면, 정확히 n 스레드를 깨운다. 그러나, 이 동작에 의존하는 것은 안전하지 않다. 미래에는, 최적화된 구현이 때때로 n 스레드보다 많이 깨울 수 있다.

  • notify_all()
    이 조건에서 대기 중인 모든 스레드를 깨운다. 이 메서드는 notify() 처럼 동작하지만, 하나 대신에 대기 중인 모든 스레드를 깨운다. 이 메서드가 호출될 때 호출하는 스레드가 잠금을 획득하지 않으면 RuntimeError가 발생한다.

답안

1억을 만들기 위한 나의 코드를 적어본다. 수정한 부분에 주석을 달아두었다.

from threading import Lock, Thread # 뮤텍스 객체 추가
import time


"""
뮤텍스를 구현한 Lock 객체의 생성으로
shared_number에 다수의 스레드가 동시에 접근하지 못하게한다.
"""
lock = Lock()
shared_number = 0

def thread_1(number):
    global shared_number
    print("number = ",end=""), print(number)
    
    for i in range(number):
        lock.acquire()        # 락을 얻는다
        shared_number += 1
        lock.release()        # 락을 해제한다

def thread_2(number):
    global shared_number
    print("number = ",end=""), print(number)
    for i in range(number):
        lock.acquire()        # 락을 얻는다
        shared_number += 1
        lock.release()        # 락을 해제한다


if __name__ == "__main__":

    threads = [ ]

    start_time = time.time()
    t1 = Thread( target= thread_1, args=(50000000,) )
    t1.start()
    threads.append(t1)

    t2 = 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
--- 18.50854778289795 seconds ---
shared_number=100000000
end of main

성공!

결론

와 진짜 길다 (ㅡㅅㅡ).

파이썬의 스레드만 다룬 책을 누가 좀 써줬으면 좋겠다. 넘나넘나 어렵다. 이 포스트도 자주 읽어보고 지속적으로 수정해줘야 할 것 같다.

암튼 끝!

profile
폐쇄맨

4개의 댓글

comment-user-thumbnail
2020년 7월 26일

와 충희님 다 잘 쓰셨지만 이번 블로그 진짜 대박이네요

1개의 답글
comment-user-thumbnail
2020년 7월 26일

수고 많으셨어요 잘 읽었습니다 :)

1개의 답글