Lock을 사용하는 스레드 동기화 방법

아래는 어떤 “counter”라는 자원을 두 스레드가 동시에 사용하려할 때, Lock을 사용하는 상황을 시각적으로 묘사한 것입니다. 두 워커 스레드 A, B 는 자원에 접근하기 전에 Lock을 획득하려고 시도합니다. 두 스레드 모두 락 객체의 .acquire()를 호출합니다. 이 때 (아마도 간발의 차이로) A 가 락을 획득하게 되었다고 가정하면, A에서 호출한 .acquire()는 즉시 리턴되어 A는 다음 코드를 진행하게 되고 여기서 counter를 사용합니다. 반면 B의 .acquire() 호출은 락을 획득할 때까지 대기하기 때문에 B의 진행 흐름은 여기서 멈추게 되고, A가 자원을 쓰는 동안 . . . 으로 묘사됩니다.

Worker A                   B
       |.acquire()         |.acquire()
        \                  . 
         |- use counter    .
         |.release()       .
        /                  \
       |.acquire()          |-use counter
       .                    |.release() 
       .                   /
       \                   |.acquire()

따라서 Lock을 올바르게 사용하기 위해서는 스레드 관점이 아닌 자원 관점에서 생각하는 것이 맞습니다. 자원 경쟁을 피하고 스레드 안전하게 처리해야 할 자원을 액세스하는 시점에 모든 스레드는 다음과 같이 코드를 작성합니다.

  1. 자원을 액세스하기 직전에 lock.acquire()를 호출합니다.
  2. 해당 자원을 사용합니다.
  3. 처리를 마치면 lock.release()를 호출하여 다른 스레드가 사용할 수 있도록 합니다.

획득-해제의 매커니즘이 두 개의 메소드 호출을 통해서 시작하고 끝나기 때문에 파이썬에서는 락 구간을 시각적으로 구분하기 힘듭니다. 이 때 lock.acquire()는 실질적으로는 락 획득 여부를 리턴하기 때문에 if 문으로 블럭을 구분해주는 것이 좋습니다. 물론 블럭의 끝에서 lock.release()를 호출하는 것을 잊으면 곤란하겠죠.

락을 쓰는 구간 앞뒤로 빠짐없이 넣어야 하는 코드 때문에 획득-해제 구간이 많으면 많을수록 코드 작성이 번거롭습니다. 하지만 threading 모듈이 제공하는 획득-해제식 동기화 수단들은 모두 with 문을 사용할 수 있습니다.

from threading import Thread, Lock, Barrier
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format='%(threadName)s %(message)s')

res = {'counter': 0}
lock = Lock()
bar = Barrier(11)


def worker():
    for _ in range(10):
        with lock:
            r = res['counter']
            res['counter'] += 1
            logging.debug(f"counter: {r} -> {res['counter']}")
    bar.wait()


def main():
    ts = [Thread(name=f'WORKER{i+1}', target=worker)
             for i in range(10)]
    for t in ts:
        t.start()
    bar.wait()


if __name__ == '__main__':
    main()

몇가지 궁금증

항상 락을 사용해야 하나?

일반적으로 외부 세계에 대한 핸들(파일 등)이 아닌 메모리 내 값이나 객체에 대해서 그것이 불변이라면 여러 스레드에서 동시에 참조해도 안전하다고 간주할 수 있습니다. 왜냐하면 변하지 않을 것이 약속되어 있다면 언제 어디서 참조하든 똑같은 값일 것을 기대할 수 있기 때문입니다. 따라서 상수를 참조하는 경우는 락을 생각하지 않아도 좋습니다.

‘쓰기’에서만 락을 적용하면 되나?

여러 스레드에서 공유하는 객체가 mutable 하다면, 쓰기 뿐만 아니라 이 객체를 읽는 동작까지 Lock을 수반해야 합니다. 최악의 경우 읽기와 쓰기가 동시에 진행될 수 있기 때문입니다. 참고로 특정한 스레드끼리만 락을 사용해서도 안됩니다. 현재 스레드가 락을 획득했다 하더라도 다른 스레드에서 해당 락을 얻는 과정을 생략해버린다면 해당 리소스에 대해 스레드 안전한 접근이 보장되지 않습니다.

blocking 과 timeout 옵션

락을 획득하기 위해 acquire() 를 호출할 때 두 가지 옵션이 있습니다. 하나는 blocking=True 이고 다른 하나는 timeout=-1 입니다. 타임아웃은 주어진 시간(초)까지만 락을 얻기 위해 대기하다가 락을 얻거나 얻지 못하고 타임아웃이 지났을 때 리턴하게 됩니다. 결국 acquire()는 락 획득 여부를 True/False로 리턴해주게 됩니다. 타임아웃의 기본 값은 -1이며, 이 경우 acquire()는 락을 획득할 때까지 무기한 기다리게 됩니다.

타임 아웃 대신 blocking=False 옵션을 사용하는 방법이 있습니다. 이 옵션을 사용하면 락을 획득하려 시도하고 즉시 결과를 리턴합니다. 논블럭 락을 사용하거나 타임아웃을 적용하는 경우, 항상 if 문을 사용하여 락을 획득하였을 때에만 선점된 리소스에 접근하도록 해야 합니다.

locked()

락 객체에 대해서 locked() 메소드는 “현재 스레드”가 해당 락을 획득하였는지 여부를 확인할 수 있습니다. 락 획득-해제 구간의 코드가 아닌 다른 함수에서 이미 락을 획득했는지 여부를 알고 싶을 때 사용할 수 있습니다.

GCD in Swift

GCD in Swift

Swift에서도 GCD를 여전히 쓸 수 있다. 먼저 dispatch_async 함수는 Objective-C 에서는 아래와 같이 쓴다.

void dispatch_async(dispatch_queue_t queue, dispatch_block_t block);

똑같은 방식으로 swift에서도 아래와 같이 정의된다.

func dispatch_async(queue:dispatch_queue_t!, block: dispatch_block_t!)

물론 swift에서 코드 블럭은 클로져이고, trailing closure 문법을 이용하면 보통은

dispatch_async(dispatch_get_main_queue()){
    println("Currently dispatched asynchronously.")
}

이런 식으로 쓸 수 있다. GCD in Swift 더보기

파이썬의 새로운 병렬처리 API – Concurrent.futures

어떤 처리량이 많은 작업을 작은 단위로 쪼개거나, 현재 진행되는 흐름과 독립적으로 병렬적인 처리를 하기 위해서 멀티스레드나 멀티프로세스를 사용하는 경우가 (지금까지는 드물지만) 종종 있다.

이전에는 Threading.ThreadMultiprocessing.Process 를 이용해서 각각의 스레드나 별도 프로세를 제어하는 방식을 사용했다. 파이썬 3.2에서 이러한 비동기 실행을 위한 API를 보다 고수준으로 만들고 사용하기 쉽도록 개선한 concurrent.futures 모듈이 도입되었다.

이 새로운 API가 기존의 스레드나 멀티프로세싱 모듈을 완전히 대체하지는 않는다. 이 모듈은 내부적으로 _thread와 같은 기존 API에 의존하고 있고, 스레드나 프로세스 제어에 대한 전반적인 방법을 제공하지도 않는다. 다만 자바스크립트 진영의 Promise 개념에 자극받아 탄생한 것으로 별도의 스레드/프로세스에서 처리한 결과를 받는 동기화 부분을 간단하게 만들어준다는 장점이 있다.

파이썬의 새로운 병렬처리 API – Concurrent.futures 더보기

[iOS/OSX] 특정 작업을 병렬로 처리하기

“동시에 진행되는 작업”을 처리하기 위해서는 iOS 및 OSX 환경에서는 크게 두 가지 방법을 (흔히) 사용한다. GCD (dispatch queue)와 Operation Queue가 그것이다. 오퍼레이션 큐는 GCD의 Objective-C 버전이라 할 만큼 비슷한데 (사실 좀 다르기는 다르다) 어쨌거나 이 두 가지 방법은 스레드의 생성과 관리를 시스템이 알아서 처리해주는 레벨로 가지고 내려가기 때문에 실제로 프로그래머가 신경써야 할 부분을 “동시에 진행되는 작업을 처리”하는 부분에만 집중하면 되도록 해준다.

예를 들면 네트워크를 통해 데이터를 로드해야 하는 경우나 그 반대로 네트워크를 통해 데이터를 저장해야 하는 경우에 응답이 느리다면 (이는 디스크 같은 영구 저장소를 액세스할 때도 일어날 수 있다. 아주 미묘한 수준이기는 하나 이런 작업은 앱에 blocking을 가져오고 UI에 대한 반응을 느리게 만든다) 이 작업의 처리를 기다리는 동안 앱은 사용자의 터치에 반응하지 못하고 계속 대기하게 될 것이다. 따라서 사용자 경험의 품질이 매우 나빠질 수 있다. 이런 경우에는 “동시작업 처리”를 하도록 해주는 것이 좋다. 동시작업 처리를 사용하면 멀티 코어 프로세서를 효율적으로 사용할 수 있고, 시스템을 보다 “바삐” 움직이게 할 수 있기 때문이다.

(*여기서 주목해야 할 부분은 “동시작업 처리”를 “멀티 스레드”로 기재하지 않은 것이다. GCD에서 동시작업을 처리하는 것은 “디스패치 큐”를 분리하여 동시에 2개 이상의 작업을 진행시키는 것인데, 놀라운 점은 GCD를 사용한 동시작업은 해당 작업에서 다시 스레드를 생성하지 않는 이상, 모두 메인 스레드에서 돌아간다. 따라서 멀티스레드가 아닌 경우가 있을 수 있다.)

NSOperationQueue를 통한 동시작업

먼저 NSOperationQueue를 사용하는 경우를 살펴보도록 하자.

만약 Block 객체를 사용하는 코딩 문법에 조금 익숙하다면 (특히 이는 애니메이션과 관련한 새로운 메소드에서 자주 등장한다. 우리가 익힌 바 있는 UIDocument 관련 글에서도 본 적이 있을 것이다.) 상당히 쉽게 익숙해질 수 있다. 즉 NSOperation은 코드 블럭과 같이 “일련의 작업을 지시하는 코드”를 객체로 만들어 이를 별도의 큐에서 실행하도록 하는 방식이다. 이 때 스레드의 생성과 관리는 큐가 알아서 하게 되므로 여전히 스레드 관리에 대한 크나큰 부담을 덜 수 있게 되는 것이다.

작업 객체 생성

NSOperation은 우리가 작업해야 하는 코드를 담는 객체인데, 이를 활용하는 방법에는 다음 세 가지가 있다.

  • NSInvocationOperation
  • NSBlockOperation
  • subclassing NSOperation
먼저 NSInvocation은 특정 객체의 메소드를 작업 객체로 만들어버리는 방법이다. 즉, 다른 스레드에서 동시 처리를 해야할 메소드를 가진 객체가 있다면, 그 객체의 메소드를 동시 처리 작업으로 만들 수 있다.
NSInvocationOperation *theOp = [[NSInvocationOperation alloc] 
                                      initWithTarget:self 
                                            selector:@selector(doMyTask:) 
                                              object:withData];

NSBlockOperation 객체는 코드 블럭을 사용해서 작업 객체를 만들 수 있다.

NSBlockOperation *theBlockOp = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"Block has started");
}];

// 아래와 같이 블럭을 계속 추가해 나갈 수 있음
[theBlockOp addBlock:^{
    // do something...
}];

혹은 NSOperation 객체를 새로 생성할 수도 있다. (이에 대한 자세한 내용은 다른 글에서 다뤄볼까 한다.)

작업 객체의 실행

작업 객체는 물론 그대로도 실행이 가능하다. 하지만 특별히 멀티 스레드로 동작하도록 작업 객체를 커스터마이징 하지 않은 경우라면 이런 작업들은 메인 스레드에서 돌아가는 함수와 동일하다. (즉, 동시작업으로 처리되지 않는다.) 별도의 스레드에서 동시 작업으로 처리되도록 하려면 NSOperationQueue 객체를 생성하여, 이 곳에 앞서 말한 방법으로 생성된 작업객체를 추가해주면 된다.

NSOperationQueue *aQueue = [[NSOperationQueue alloc] init];
[aQueue addOperation:anOp];

작업이 추가되면 큐 객체는 자동으로 스레드를 만들고 먼저 큐에 추가된 순서대로 작업 객체에 start 메시지를 보내어 각각의 작업을 시작하게 된다.

단순한 예제를 만들 때 유의할 점은, 큐가 스레드를 새로 생성할 때는 약간의 시간이 걸리는 데, 그 사이에 메인 스레드가 종료되어 버린다면 큐에 담긴 작업이 아예 처리되지 못하고 프로그램이 종료될 수도 있다.

이런 예제와 같은 경우에는 큐를 처리하는 동안 큐를 생성했던 현재 스레드를 잠깐 멈추게 하여 큐가 처리된 이후에 그 다음 작업을 실행해주는 방법도 있다.

[aQueue waitUntilAllOperationsAreFinished];

하지만 이렇게 큐의 작업이 처리되는 것을 기다리는 것은 성능에도 좋지 않은 영향을 미치고 (왜냐면 그만큼 메인 스레드가 블럭킹을 당하고 잠기기 때문에) 되려 동시 작업성을 저해하는 결과를 가져오기 때문에 가능하면 쓰지 말 것을 권한다.

큐에서 작업을 시작할 때는 작업 객체에 start 메시지를 보낸다. 이와 같은 방법으로 NSInvocationOperation 객체나 NSBlockOperation 객체에 start 메시지를 보내 해당 작업을 실행시킬 수 있다. 하지만 메인 스레드에서 명시적으로 이런 작업을 실행하는 것은 그냥 코드 블럭을 실행하는 것과 아무런 차이가 없게된다.

Dispatch Queue 사용하기

Dispatch Queue도 큐에 코드 블럭을 밀어넣어 실행하는 것과 유사하게 디스패치 큐에 작업(코드 블럭)을 넣고 이를 동시에 실행시키는 방법이다. 동시 작업으로 진행될 작업은 메인 스레드에서 함께 돌아간다. 이것이 오퍼레이션 큐와의 가장 큰 차이점이라 하겠다. (실은 항상 메인 스레드에서 돌아가는지는 모르겠다. 동시에 처리되는 작업의 개수도 시스템이 코어의 개수나 시스템에 현재 걸려 있는 부하에 따라 자동으로 판별한다.

즉 동시 작업으로 병렬처리되는 일이 종료되었을 때 어떤 일이 이어서 일어나게 만들고자 할 때 (이때는 델리게이션이나 KVO를 써도 되지만) 이 방법을 사용하는 것도 굉장히 쉽고 간단하다. GCD를 이용해서 병렬작업을 처리하는 가장 간단한 방법은 글로벌 큐를 사용하는 것이다. 물론 글로벌 큐를 사용하지 않고 별도의 큐를 생성하여 작업을 처리할 수도 있다. 단 이렇게 생성되는 큐는 serial 큐로, 추가된 순서대로 작업이 수행된다. 대신 글로벌 큐는 들어간 순서대로 작업이 시작되나, 큐에 들어간 작업은 가능한 많은 수가 동시에 실행되므로 먼저 들어간 작업이 먼저 끝난다고는 특정할 수 없다.

큐에 작업을 추가하여 실행하기 위해서는 dispatch_async 함수를 사용한다. 이 함수에 수행할 작업을 코드 블럭으로 넘겨서 수행하도록 할 수 있다. 이 함수를 호출한 직후 프로그램의 흐름은 다음 라인으로 넘어가고, 디스패치 큐는 이와 동시에 넘겨진 작업을 즉시 처리하게 된다. 다음과 같이 글로벌 큐를 적용한 아주 간단한 코드를 사용해서 병렬 작업을 수행할 수 있다.

dispatch_queue_t aQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(aQueue, ^{
        // 병렬적으로 진행할 코드
        });

매우 큰 DB에서 값을 검색해 오거나, 인터넷을 통해 데이터를 다운로드 받아서 처리해야 하거나, 영구저장소에 저장된 파일을 액세스 하는 등 시간이 걸릴 수 있는 일을 처리하는 경우에는 메인스레드가 blocking 될 수 있으므로 이렇게 처리해주면 백그라운드에서 돌아가는 것처럼 처리되고 UI 반응은 멈추지 않고 계속 이루어질 수 있다.

만약 저렇게 큐에서 돌아가는 작업이 끝나거나 혹은 그 중간에 UI를 업데이트 하거나 해야 한다면, UI 갱신을 처리하는 부분은 메인 큐이므로 메인 큐에서 필요한 작업을 처리할 수 있다. 즉 메인 큐 ▶ 글로벌 큐에서 동시작업 ▶ 메인큐에서 작업 하는 식으로 중간에 메인 큐에 끼어들 수도 있다.

dispatch_queue_t aQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(aQueue, ^{
        // 병렬적으로 진행할 코드
        dispatch_async(dispatch_get_main_queue(), ^{
            //메인큐에서 UI 업데이트 등을 실행
            });
        });

참고자료 : Concurrency Programming Guide