[번역글] Concurrent Programming: APIs and Challenges

This posting is a Korean translation of “Objc.io”‘s contents. Please contact me if I need to remove it. Thank you.

Original article: Concurrent Programming: APIs and Challenges

 

동시성(Concurrency)라는 개념은 여러 과업(tasks)이 같은 시간에 처리되는 것을 뜻한다. 이것은 싱글 CPU 코어 환경에서는 시간 분할 처리 방식이나, 멀티 CPU 코어 환경에서 진정한 병렬 처리 방식과 같이 구현될 수 있다.

OSX와 iOS는 병행 프로그래밍(Concurrent programming)을 가능케 하는 다양한 API를 제공한다. 각각 API는 다 다른 능력과 한계를 가지고 있으며, 여러 종류의 과업에 알맞게 설계되었다. 그리고 각 API는 다 다른 추상화(Abstraction) 정도의 레벨로 구현되어 있다. 하위 레벨 정도의 조작이 가능하지만 그에 따른 책임도 따른다.

병렬 프로그래밍은 난해한 문제들과 상황들이 존재하는 매우 까다로운 주제이며, GCD나 NSOperationQueue와 같은 API를 사용하면서 쉽게 까먹을 수 있는 개념이다. 먼저 이 글에서는 OSX와 iOS의 병렬처리 관련된 API를 전체적으로 살펴 볼 것이며, 전부터 내려오던 병렬 프로그래밍의 어려운 과제들에 대해서 깊이 알아볼 것이다.

 

OS X와 iOS의 병렬 API

애플의 모바일과 데스크톱 OS는 병렬 프로그래밍을 위한 동일한 API를 제공한다. 이 글에서는 pthread와 NSThread, GCD, NSOperationQueue, 그리고 NSRunLoop을 살펴 볼 것이다. 기술적으로 run loop이 소개 된다는 것이 이상할지 모른다. 왜냐하면 진정한 병렬처리와 관계가 없기 때문이다. 하지만 이 주제와 함께 자세히 알아볼 필요가 있다고 생각한다.

우리는 먼저 하위 레벨 API를 시작으로 점점 상위 레벨 API를 살펴 볼 것이다. 이렇게 작성한 이유는 상위 API들은 하위 API들 위에 만들어진 것이기 때문이다. 아무튼 실제 개발에서는 지금과 같은 방식이 아닌 상위 API를 먼저 검토하고 사용해야 할 것이다. 왜냐하면 코드를 쉽게 만들고 병렬 처리 모델을 더 심플하게 유지 할 수 있기 때문이다.

만약 우리가 왜 상위 레벨 추상화와 매우 심플한 병렬 처리 코드를 계속적으로 추천하는지 궁금 할 것이다. 그렇다면 이글의 두번째 부분과 Peter Steinberger가 작성한 쓰레드 세이프관련 포스트를 읽어보길 추천한다.

 

쓰레드

쓰레드는 운영체재의 스케쥴러에 의해서 독립적으로 스케쥴이 잡히는 프로세스의 하위 유닛이다. 가상으로 GCD와 NSOperationQueue의 모든 병렬 처리 API는 쓰레드 위해서 만들어 졌다.

하나 이상의 쓰레드는 싱글 CPU 코어에서도 실행될 수 있다. (최소한 느끼기에 동시에 실행되고 있다고 느끼게 해준다.) 운영체제는 각 쓰레드에게 짧은 시간을 할당해 주며 계산을 하는데, 사용자는 여러 쓰레드가 동시에 실행 되는 처럼 느낄 것이다. 만약 하나 이상의 CPU 코어가 탑재 되어 있다면 실제로 병렬 처리를 하여 특정 과업의 전체 시간을 줄여 줄 것이다.

멀티 CPU 코어에서 당신의 코드와 당신이 사용하고 있는 프레임워크 코드가 실행 스케쥴을 얼마나 잡는지 확인하고 싶다면, XCode의 Instruments의 CPU strategy를 사용하된다.

마음에 염두하고 있어야 할 것은 우리에게는 언제 어디서 작성한 코드가 스케쥴 잡힐지 모른다는 것이다. 그리고 또한 언제 얼마나 길게 할당을 위해 기달려야 자신의 차례가 오는지도 알 수 없다. 이런 종류의 쓰레드 스케쥴링은 매우 강력한 테크닉이다. 하지만 매우 난이도 높은 작업이다. 우리는 앞으로 이것을 조사해 볼 것이다.

복잡도에 대한 내용은 잠시 뒤로하고, POSIX 쓰레드 API를 사용하든지 Objective-C로 랩핑되어 있는 NSThread를 사용하여 자신만의 쓰레드를 만들수 있다. 다음은 pthread를 사용하여, 100만의 숫자 가운데서 가장 작은 수와 가장 큰수를 찾는 간단한 샘플 코드이다. 4개의 쓰레드가 병렬 처리하면서 찾을 것이다. 코드를 살펴 보면 왜 pthread를 직접 쓰지 말아야 할 지 알게 될 것이다.

 

  1. #import <pthread.h>
  2.  
  3. struct threadInfo {
  4.     uint32_t * inputValues;
  5.     size_t count;
  6. };
  7.  
  8. struct threadResult {
  9.     uint32_t min;
  10.     uint32_t max;
  11. };
  12.  
  13. void * findMinAndMax(void *arg)
  14. {
  15.     struct threadInfo const * const info = (struct threadInfo *) arg;
  16.     uint32_t min = UINT32_MAX;
  17.     uint32_t max = 0;
  18.     for (size_t i = 0; i < info->count; ++i) {
  19.         uint32_t v = info->inputValues[i];
  20.         min = MIN(min, v);
  21.         max = MAX(max, v);
  22.     }
  23.     free(arg);
  24.     struct threadResult * const result = (struct threadResult *) malloc(sizeof(*result));
  25.     result->min = min;
  26.     result->max = max;
  27.     return result;
  28. }
  29.  
  30. int main(int argc, const char * argv[])
  31. {
  32.     size_t const count = 1000000;
  33.     uint32_t inputValues[count];
  34.  
  35.     // Fill input values with random numbers:
  36.     for (size_t i = 0; i < count; ++i) {
  37.         inputValues[i] = arc4random();
  38.     }
  39.  
  40.     // Spawn 4 threads to find the minimum and maximum:
  41.     size_t const threadCount = 4;
  42.     pthread_t tid[threadCount];
  43.     for (size_t i = 0; i < threadCount; ++i) {
  44.         struct threadInfo * const info = (struct threadInfo *) malloc(sizeof(*info));
  45.         size_t offset = (count / threadCount) * i;
  46.         info->inputValues = inputValues + offset;
  47.         info->count = MIN(count – offset, count / threadCount);
  48.         int err = pthread_create(tid + i, NULL, &findMinAndMax, info);
  49.         NSCAssert(err == 0, @"pthread_create() failed: %d", err);
  50.     }
  51.     // Wait for the threads to exit:
  52.     struct threadResult * results[threadCount];
  53.     for (size_t i = 0; i < threadCount; ++i) {
  54.         int err = pthread_join(tid[i], (void **) &(results[i]));
  55.         NSCAssert(err == 0, @"pthread_join() failed: %d", err);
  56.     }
  57.     // Find the min and max:
  58.     uint32_t min = UINT32_MAX;
  59.     uint32_t max = 0;
  60.     for (size_t i = 0; i < threadCount; ++i) {
  61.         min = MIN(min, results[i]->min);
  62.         max = MAX(max, results[i]->max);
  63.         free(results[i]);
  64.         results[i] = NULL;
  65.     }
  66.  
  67.     NSLog(@"min = %u", min);
  68.     NSLog(@"max = %u", max);
  69.     return 0;
  70. }

 

NSThread는 pthread를 랩핑한 Objective-C 클래스이다. 이렇게 랩핑을 한 것이 코코아 환경에서 더 익숙해 보인다. 예를 들어, 백그라운드에서 은닉화하고 싶은 코드를 쓰레드로 작성하고 싶다면, NSThread를 상속 받아서 작성하면 된다.

&nbsp

  1. @interface FindMinMaxThread : NSThread
  2. @property (nonatomic) NSUInteger min;
  3. @property (nonatomic) NSUInteger max;
  4. - (instancetype)initWithNumbers:(NSArray *)numbers;
  5. @end
  6.  
  7. @implementation FindMinMaxThread {
  8.     NSArray *_numbers;
  9. }
  10.  
  11. - (instancetype)initWithNumbers:(NSArray *)numbers
  12. {
  13.     self = [super init];
  14.     if (self) {
  15.         _numbers = numbers;
  16.     }
  17.     return self;
  18. }
  19.  
  20. - (void)main
  21. {
  22.     NSUInteger min;
  23.     NSUInteger max;
  24.     // process the data
  25.     self.min = min;
  26.     self.max = max;
  27. }
  28. @end

 

새로운 쓰레드를 시작하려면 쓰레드 객체를 만들어서 다음과 같이 start 메서드를 호출해야 한다.

  1. NSMutableSet *threads = [NSMutableSet set];
  2. NSUInteger numberCount = self.numbers.count;
  3. NSUInteger threadCount = 4;
  4. for (NSUInteger i = 0; i < threadCount; i++) {
  5.     NSUInteger offset = (numberCount / threadCount) * i;
  6.     NSUInteger count = MIN(numberCount – offset, numberCount / threadCount);
  7.     NSRange range = NSMakeRange(offset, count);
  8.     NSArray *subset = [self.numbers subarrayWithRange:range];
  9.     FindMinMaxThread *thread = [[FindMinMaxThread alloc] initWithNumbers:subset];
  10.     [threads addObject:thread];
  11.     [thread start];
  12. }

 

먼저 결과를 평가하기 전에 우리가 새로 작성한 쓰레드의 모든 코드가 끝나고 나서 thread 객체의 isFinished 프로퍼티를 알고 싶다면, 관심이 있는 독자들은 한번 직접 짜보길 권한다. 여기에서 진짜 말하고 싶었던 부분은 쓰레드를 직접 사용해 본다는 부분이다. pthread를 사용하든지, NSThread의 API를 사용하는 것은 우리 멘탈 모델에 적합하지 않는 비교적 시대에 뒤떨어지는 경험일지 모르지만 말이다.

마음에 직접적으로 떠오르는 한 가지 문제는, 만약 당신의 코드나 프레임워크 코드가 자신들의 쓰레드를 만들어 작동 한다면, 활성화 되는 쓰레드의 숫자가 기하급수적으로 늘어난다는 것이다. 해당 이슈는 큰 프로젝트에서 자주 발생하는 문제이다. 예들 들어, CPU 코어가 8개가 있는 환경에서 이점을 챙기기 위해 쓰레드 8개를 생성해서 사용한다고 치자, 각 쓰레드에서 위 로직이 들어 있는 프레임워크를 사용하게 되면, 결국 짧은 시간내에 수 백개나 되는 쓰레드를 만들어 낼 것이다. 각 코드들은 자신들의 책임을 다하여 작동할 것인데, 그럼에도 불구하고 결과는 좋은 모습이 아니다. 쓰레드를 잘 사용하기는 쉽지 않다. 각 쓰레드는 메모리와 커널 자원과 연결 되어 있기 때문이다.

다음으로, 우리는 큐 기반의 병령처리 관련 API를 살펴 볼 것이다. GCD와 NSOperationQueue인데, 이것들은 집중적으로 쓰레드 풀를 관리하여 해당 문제점들을 완화해 준다.

Grand Central Dispatch

늘어나는 사용자 디바이스의 CPU 코어 갯수의 이점을 개발자들이 누릴 수 있도록 애플은 Grand Central Dispatch를 OSX 10.6과 iOS4에서 부터 제공하기 시작했다. 하위 레벨의 병렬처리 API인 GCD에 대해서 이 글에서 더 자세히 알아 볼 것이다.

GCD를 사용하면 이제 더 이상 쓰레드를 직접적으로 상대하지 않아도 된다. 대신에 코드 블럭을 큐에 넣으면 밑 물에서 GCD가 쓰레드 풀을 관리 한다. GCD는 어떤 특정 코드 블럭의 쓰레드를 실행할지 정하고, 시스템의 가용 리소스에 따라서 쓰레드들을 관리한다. 쓰레드는 중앙 통제적으로 관리 되어지기 때문에 쓰레드 때문에 생기는 많은 문제점들을 줄어들게 된다.

또 다른 이점으로는 어플리케이션 개발자 입장에서는 복잡한 쓰레드 개념보다 큐 개념을 가지고 개발할 수 있게 되었다는 점이다. 새로운 병렬처리 개념은 기존 방식에 비해 쉽게 작업할 수 있다.

GCD는 다섯 종류의 큐를 제공한다. 메인 쓰레드위에서 도는 메인 큐, 세 개의 우선순위가 다른 백그라운드 큐, 그리고 I/O 처리같은 작업을 할 수 있는 보다 더 낮은 우선순위를 가지고 있는 백그라운드 큐이다. 더 나아가, 순차적 혹은 비동기식 커스텀 큐를 만들 수도 있다. 이렇게 강력한 추상화를 지닌 커스텀 큐는 특정한 하나의 글로벌 큐에 끼어 들어가게 되어 해당 쓰레드 풀에서 연관되게 된다.

Thread Pool

처음 다른 우선순위로 여러가지 큐를 만들어 사용한다는 것은 직관적으로 보일지 모른다. 하지만 우리는 어떤 경우에든지 여러분이 디폴트 우선순위가 지정되어 있는 큐를 최대한 사용하라고 권하고 싶다. 공유자원을 접근하는 과업을 작업하다 보면, 실행고자 하는 과업을 각 다른 우선순위와 함께 큐에 스케쥴링한다는 것이 예상하지 못한 작동을 불러이르킨다는 것을 알게 될 것이다. 또한 낮은 우선순위 과업이 높은 우선순위 과업을 가로막는 일이 잦아져, 프로그램 전체가 점점 느려지다가 멈추게 만들기도 한다. 이런 현상을 우선순위 전도(Priority Inversion)라고 부른다. 이 글 뒷부분에서 더 자세히 다룰 것이다.

비록 GCD는 하위 레벨 C API이지만, 사용하는데 있어서 직관적이다. 디스페칭 블럭을 GCD의 큐에 적용하므로써 병렬처리 프로그램에서 발생하는 어려움들과 알고 있는 문제점들을 생각나지 않게 된다. 병렬 프로그래밍에서 발생 가능한 문제들에 대한 내용을 서술한, 해당 글의 병렬처리 프로그래밍이 처한 난관들을 꼭 읽어보기 바란다. 더 나아가 다음 포스팅에서는 GCD를 통해 이러한 어려운 난관들을 어떻게 해결할 수 있는지 알아 볼 것이다.

 

Operation Queue

Operation 큐는 GCD API 기반으로 만들어진 추상화 된 코코아의 큐 모델이다. GCD는 Operation 큐에 비해 낮은 레벨에서 컨트롤을 할 수 있게 해주지만, Operation 큐는 어플리케이션 개발자들에게 보다 안전하고 편리한 기능을 제공한다.

NSOperationQueue는 메인 큐(main queue)와 커스텀 큐(custom queue)로 구분 할 수 있다. 메인 큐는 메인 쓰레드 위에서 돌며. 커스텀 큐는 백그라운드에서 진행이 된다. 어떤 경우든, 해당 큐에서 진행되는 과업들은 NSOperation을 상속받은 서브 클래스들이다.

You can define your own operations in two ways: either by overriding main, or by overriding start. The former is very simple to do, but gives you less flexibility. In return, the state properties like isExecuting and isFinished are managed for you, simply by assuming that the operation is finished when main returns.

사용자는 main 메서드를 오버라이드 한다든지, start 메서드를 오버라이드하는 방식으로 두 가지 방법으로 자신만의 operation을 정의할 수 있다. 첫 번째 방식(main 메서드를 오버리이드 하는 방식)은 간단하지만 사용자가 할 수 있는 것이 제한 되어있다.

  1. @implementation YourOperation
  2.     – (void)main
  3.     {
  4.         // do your work here …
  5.     }
  6. @end

만약 좀 더 많은 컨트롤을 원하거나, 비동기식 작업을 해당 operation에서 실행하기 원한다면 start 메서드를 오버라이드하면 된다.

  1. @implementation YourOperation
  2.     – (void)start
  3.     {
  4.         self.isExecuting = YES;
  5.         self.isFinished = NO;
  6.         // start your work, which calls finished once it's done …
  7.     }
  8.  
  9.     – (void)finished
  10.     {
  11.         self.isExecuting = NO;
  12.         self.isFinished = YES;
  13.     }
  14. @end

만약 start 메서드를 오버라이드 해서 사용하고자 할때 주의 할 점은 operation의 상태를 수동으로 관리해야한다. Operation 큐가 큐 안에 있는 Operation을 선택하여 실행하기 위해서는 KVO 방식을 사용하여 상태 프로퍼티 값을 설정해주어야 한다. 그러므로 만약 디폴트 접근자를 사용하지 않고 있을 경우에는, 올바르게 해당 KVO 메세지를 보내고 있는지 주의해야 할 것이다.

Operation queue에서 긴 작업을 위해 제공하는 작업 취소하기 기능을 제대로 사용하기 위해서는 ‘isCancelled’ 프로퍼티를 주기적으로 확인해야 한다.

  1. - (void)main
  2. {
  3.     while (notDone && !self.isCancelled) {
  4.         // do your processing
  5.     }
  6. }

사용자가 Operation 큐를 정의만 해놓으면 큐에 넣고 사용하는 것은 매우 쉽다.

  1. NSOperationQueue *queue = [[NSOperationQueue alloc] init];
  2. YourOperation *operation = [[YourOperation alloc] init];
  3. [queue  addOperation:operation];

반면, 클래스를 정의해서 사용하지 않아도 블럭을 큐에 삽입해서 작업 할 수 있다. 이 방식은 간편하다. 예를 들어 사용자가 한번만 사용할 작업이라면 다음 코드와 같이 큐에 넣어 사용하면 된다.

  1. [[NSOperationQueue mainQueue] addOperationWithBlock:^{
  2.     // do something…
  3. }];

비록 위 코드와 같이 블록을 사용하여 스케쥴링하면 매우 편리하지만, NSOperation을 상속 받아서 작업하는 방식은 디버깅에 매우 유용하다. 사용자가 만약 description 메서드를 오버라이드하면 현재 특정 큐 안에 있는 operations들을 쉽게 알수 있기 때문이다.

operation이나 블록을 스케쥴링하는데 있어서, GCD에서 제대로하기 쉽지 않은 몇가지 작업을 operation 큐에서 제공해주고 있다. 예를 들어 maxConcurrentOperationCount 프로퍼티를 사용하여 특정 큐에서 병렬처리 할 과업의 수를 관리하기에 용의하다. 시리얼 큐에 설정을 할 시 다른 작업과 격리시키기 위한 목적으로는 사용할 수 있다.

또 다른 편리한 기능으로는 우선순위에 따라 특정 큐의 작업들의 정렬하는 기능이다. 이 기능은 GCD의 큐의 우선순위와 다른 기능이다. 오로지 하나의 큐에 스케쥴 잡힌 과업들의 실행에 영향을 주는 기능이다. 만약 다섯 가지 우선순위를 넘어서, 실행 순서가 정해져 있는 작업을 해야 한다면, 작업 간에 의존도를 부여해 줌으로써 원하는 순서에 실행을 시킬수 있다.

  1. [intermediateOperation addDependency:operation1];
  2. [intermediateOperation addDependency:operation2];
  3. [finishedOperation addDependency:intermediateOperation];

이 코드는 intermediateOperation를 operation1과 operation2가 끝내고서 실행이 되도록 의존도를 설정해 주고 있으며, finishedOperation은 intermediateOperation이 끝나면 실행이 될 것이다. Operation 의존도 실행 순서를 잘 정의해 주는 대단히 강력한 매카니즘이다. 이것은 사용자가 의존하고 있는 operation이 끝나야지만 실행 할 수 있도록 보장해 주는 operation 그룹 같은 것을 만들수 있게 해준다.

추상화의 기본적 특성 때문에 operation 큐는 GCD API와 비교하면 성능이 약간 떨어진다는 점이 있다. 하지만 대부분의 경우에는 임팩트가 약하며, operation 큐를 선택 사용하는 것이 좋은 선택이라고 생각한다.

Run Loops

런 루프는 과업들을 병렬실행 해주지 않기 때문에 기술적으로 GCD나 Operation 큐와 같은 병렬처리 매카니즘은 아니다. 하지만 런 루프는 직접적으로 메인 operation 큐와 메인 dispatch에서 실행되는 과업들과 연관이 있으며, 비동기식 코드 실행에 관한 매카니즘을 제공한다.

런 루프는 GCD나 Operation 큐를 사용하는 것 보다 헐씬 더 쉽게 사용할 수 있다. 왜냐하면 사용자는 병렬처리 대한 복잡한 것을 고려하지 않아도 비동기식으로 일을 해낼수 있기 때문이다.

런 루프는 언제나 하나의 특정한 쓰레드에서 작동된다. 메인 쓰레드와 연관되어 있는 메인 런 루프는 UI, 이벤트, 타이머, 그리고 커널 이벤트를 처리하기 때문에 코코아와 코코아 터치 어플리케이션에 중심적인 역할을 하고 있다. 사용자가 타이머를 사용하여 스케쥴를 잡거나, NSURLConnection을 사용하거나, 혹은 performSelector:withObject:afterDelay: 메서드를 호출 하면, 비동기식 작업을 처리하는 런 루프가 사용된다.

런 루프를 사용할 때마다 기억해야 할 것은 런 루프는 여러가지 모드로 실행 할 수 있다는 것이다. 각 모드는 런 루프가 반응하게 될 이벤트들을 정의한다. 이 방식은 메인 런 루프에 있는 과업들을 넘어 임시적으로 특정 과업들에게 우선순위를 정할 수 있는 좋은 방식이다.

이것에 대한 iOS의 예로 스크롤링을 될 수 있다. 사용자가 스크롤링을 하는 동안 런 루프는 디폴트 모드로 작동하지 않는다. 그렇기 때문에 예를 들어 타이머는 반응하지 않을 것이다. 스크롤링이 끝나면 런 루프는 디폴트 모드에 반환하고 큐에 적소된 다음 이벤트를 실행하게 될 것이다. 만약 스크롤링 중에 타이머가 작동하기 원한다면 런 루프의 NSRunLoopCommonModes에 추가해야 할 것이다.

메인 쓰레드는 항상 메인 런 루프를 설정하고 실행한다. 다른 쓰레드는 기본적으로 런 루프가 설정되어 있지 않는다. 사용자는 다른 쓰레드에도 런 루프를 설정할 수 있지만, 이런 경우는 거의 드물 것이다. 대부분은 메인 런 루프를 사용하는 것이 쉽다. 만약 메인 쓰레드에서 오래 걸리는 과업을 실행하고 싶지 않다면, 메인 런 루프로 부터 다른 큐에 넣어서 작동하게 하면 될 것이다. 크리스가 쓴 블로그 포스트에서 좋은 예재를 소개할 것이다.

그래도 만약 다른 쓰레드에 런 루프를 설정하여 사용하고 싶다면, 하나 이상의 입력 소스를 추가하는 것을 잊지 말아야 할 것이다. 만약 런 루프가 입력 소스가 설정되어 있지 않으면, 매번 실행하고 할 때 갑자기 종류될 것이다.

병렬처리 프로그램이 직면한 어려움들

병렬처리 프로그래밍을 작성하는 데는 많은 어려움이 따른다. 아무리 기본적인 작업을 하려고 해도 상호작용하는 여러 과업들이 병렬처리 될때 각각의 다른 상태를 감독하는 것이 어려워진다. 문제들은 병렬처리 코드를 디버깅하기 어려운 결정되지 않은 방식으로 나타다는 것이다.

지금부터 소개할 것은 병렬처리 프로그래밍의 예견치 못한 작동에 대한 예이다. 1995년 NASA는 화성에 정찰기를 보냈다. 탐사선이 화성에 성공적으로 착륙한지 얼마 지나지 않아 미션은 거의 실패로 돌아갈 위기에 처했다. 화성 탐사선은 원인불명으로 계속적으로 재 부팅이 되기 시작한 것이다. 낮은 우선순위 작업이 높은 우선순위 작업을 지속적으로 블록킹하여 나타나는 ‘우선순위 역전’ 현상으로 어려움을 겪고 있는 것이었다. 우리는 더 자세한 ‘우선순위 역전’ 현상에 대해서는 알아 볼 것이다. 그러나 이 예가 말해주고 있는 것은 엄청난 자원과 뛰어난 엔지니어들에게도 생긴 똑같은 어려움들이 당신에게 여러가지 방식으로 다가올 것이라는 것을 말해주고 있다.

공유자원

멀티 쓰레드에서 발생하는 수 많은 문제들의 근본지는 공유자원에 접근이다. 여기에서 말하는 자원은 클래스의 프로퍼티나 객체, 혹은 보통 경우 메모리, 네트워크 기기, 파일 등이 될 수 있다. 멀티 쓰레드 사이에서 공유하는 모든 것이 잠재적인 문제점이라고 할 수 있다. 그리고 반드시 안전한 측정을 취함으로 이러한 문제점들을 피해야 할 것이다.

소개한 문제를 재현해보기 위해 카운터로 사용하는 정수 프로퍼티 방식으로 되어 있는 자원을 예로 들어 보자. A와 B라고 하는 두 개의 쓰레드가 카운터를 동시에 평행하게 돌아간다고 가정해 보자. 문제는 C나 오브젝티브 C의 작성된 코드는 그냥 하나의 CPU의 기계식 명렁어 아니라는 것이다. 카운터의 숫자를 하나 올리기 위해서는, 현재 카운터의 값을 메모리에서 부터 불러와야 할 것이고, 해당 값을 하나 올린 다음에 다시 메모리에 써야할 것이다.

두 쓰레드가 동시에 처리하려고 시도하는 것을 상상해보라. 예를 들어, 쓰레드 A와 쓰레드 B가 17인 카운터의 값을 메모리로 부터 읽었다고 가정해보자. 쓰레드 A가 카우터의 값을 하나 올린 다음 18를 메모리에 쓰고, 쓰레드 B도 값을 하나 올린 다음 18이라는 값을 메모리에 썼다고 생각해 보라. 이 해당되는 데이터는 두번 값을 올렸음에도 실질적으로 1 밖에 안올라가는 오류가 생긴 것이다.

Screen Shot 2015-10-06 at 11.15.10 PM

이런 문제를 경함 조건(race condition)이라고 부르며, 멀티 쓰레드 환경에서 특정 쓰레드가 작업이 끝나기도 전에 다른 쓰레드가 동시에 자원에 접근할 때 항상 발생한다. 이런 상황을 막기 위해서는 멀티 쓰레드는 상호 베타적인 방식으로 공유자원에 접근해야 한다.

현실에서는 현존하는 CPU들이 최적화라는 이유로 읽기-쓰기 순서를 계속적으로 바꾸기(Out of order execution) 때문에 예재 보다 헐씬 복잡하다.

상호 베타

상호 베타적 접근이라는 뜻은 특정 자원에 한번에 하나의 쓰레드만 접근한다는 것을 말한다. 이것을 보장하기 위해서는 공유 자원에 접근하고자 하는 쓰레드는 먼저 뮤텍스 락을 획득하는 것이 필요하다. 자신의 작업이 끝나면 락을 풀어줌으로써 다른 쓰레드가 접근할 수 있는 기회를 얻어야 한다.

Screen Shot 2015-10-06 at 11.26.27 PM

상호 베타적 접근을 보장하기 위해서 추가적으로, 락은 반드시 순서 바꾸기 실행(out-of-order execution)을 통해 생기는 문제점들을 처리해야 한다. 프로그램 명령에 의해 정의된 순서에 따라 CPU가 메모리에 접근하는 것만 의지 해서는 부족하다. 사이드 이팩트를 피하는 방법은 메모리 방벽(barrier)을 사용하는 것이다. 메모리 방벽을 설정하면 순서 바꾸기 실행이 방벽에서는 생기지 않는다.(Setting a memory barrier makes sure that no out-of-order execution takes place across the barrier.)

물론 뮤텍스 락를 구현하는 것 자체로 경합 조건을 피할수 있다. 간단한 일이 아니며 현존하는 CPU에 대한 특별조취를 사용하는 것이 요구된다. 더 자세한 내용을 알고 싶다면 데니엘의 낮은 수준의 병렬처리 테크닉에 대한 글를 읽어보기 바란다.

Objective-C는 프로퍼티 선언시 atomic이라는 키워드 사용을 통해 언어적 레벨의 락 기능을 지원하고 있다. 프로퍼티를 atomic으로 선언하는 것은 공유자원의 매 접근시도 시 락과 언락을 해주는 결과를 가져다 준다. 만약을 위해 모든 프로퍼티를 atomic으로 선언해 줄수도 있지만 항상 비싼 비용이 든다는 것을 생각해야 한다.

자원에 락을 건다는 것은 언제난 성능적 비용든다. 락을 걸고 푸는 것은 멀티코어 시스템에서 구현하기 쉽지 않은 경함조건을 없는 것이 필요하다. 또한 락을 걸면 쓰레드는 먼저 자원은 사용하고 있는 다른 쓰레드 때문에 기달려야 한다. 이런 경우에는 sleep에 빠지게 되고 사용하던 쓰레드의 작업이 끝나면 노티를 받게 된다. 이모든 처리는 비용이 비싸고 어렵다.

또 다른 종류의 락이 있다. 어떤 락은 매우 저렴하지만 자원 경쟁에 약하다. 어떤 락은 기본적으로 비용이 비싸지만 자원 경쟁에서 강력하다.

락을 걸고 푸는 것에는 장단점이 있다. 그러므로 critical 섹션에 자주 들락거리는 것은 삼가해야 할 것이다. 동시에, 긴 실행 코드로 인해 락을 오래 잡는 것은 자원을 쓰고자 하여 락을 걸길 원하는 다른 쓰레드의 일을 하지 못하게 할 수도 있다. 참 어려운 문제이다.

동시에 실행하도록 짜여진 코드는 쉽게 접할 수 있지만, 공유 자원을 락하는 방식이 정해져 있어서 실제로 결과적으로는 하나의 쓰레드만 활성화 되는 결과를 낳는다. 사용자는 CPU 코어가 그들의 코드를 언제 스케쥴 잡아 사용할지를 판단하기 쉽지 않다. XCode의 Instrument의 CPU strategy 뷰를 통해 CPU 코어 할당을 효율적으로 사용하고 있는지 알아 볼수는 있을 것이다.

Dead Locks

뮤텍스 락은 경합조건을 해결해주지만 동시에 데드락이라는 새로운 문제도 만들어 낸다. 데드락은 하나 이상의 쓰레드가 다른 쓰레드의 작업이 끝나길 서로 기다리면서 생기는 현상이다.

Screen Shot 2015-10-13 at 1.22.50 PM

두 값을 스와핑하는 다음 예제를 한번 살펴보자.

  1. void swap(A, B)
  2. {
  3.     lock(lockA);
  4.     lock(lockB);
  5.     int a = A;
  6.     int b = B;
  7.     A = b;
  8.     B = a;
  9.     unlock(lockB);
  10.     unlock(lockA);
  11. }

위 코드는 거의 항상 잘 돌 것이다. 하지만 쓰레드가 동시에 상대방의 값으로 호출할 때는 발생하게 된다.

  1. swap(X, Y); // thread 1
  2. swap(Y, X); // thread 2

위와 같이 샐행하면 데드락에 빠질 것이다. 쓰레드 1은 x에 락을 걸것이고, 쓰레드 2는 y에 락을 것 것이다. 이렇게 된다면 두 쓰레드는 서로의 락이 풀리길 기다릴 것이며, 영원히 끝나지 않을 것이다.

다시한번 말하지만, 공유자원을 많은 쓰레드에 공유하거나, 락을 더 많이 걸 수록, 데드락에 빠질 위험을 더 커진다. 그렇기 때문에 최대한 간단하게 코딩을 해야 할 것이며, 쓰레드 사이에 공유자원을 최소화 해야한다. 로우 레벨의 병렬처리 API에 관한 글도 꼭 읽어 보길 바란다.

Starvation

고려해야 하는 상황이 많다고 생각할지 모르지만 새로운 난제를 하나 더 소개하고자 한다. 공유자원을 락하는 것으로 인해 읽기-쓰기 문제를 초래할 수있다. 많은 경우에 자원을 읽기 위한 접근을 한번에 하나씩 하는 것은 낭비일지도 모른다. 그러므로 리소스에 쓰기 락이 없는 한 읽기 락을 허용하는 경우가 많다. 이럴 경우에는 한 쓰레드에서 쓰기위한 락을 걸기위해 읽기위한 락들이 풀리길 기다리다가 굶줄이는 경우가 생긴다.

이 문제점을 해결하기 위해서는 읽기-쓰기 락과 같은 간단한 방법보다는 현명한 솔루션이 필요하다. 예를 들어 writer preference를 주거나 읽기-복사하기-갱신하기 알고리즘(read-copy-update algorithm)을 사용한다. 데니엘이 그가 쓴 로우 레벨 병렬처리 기법에 관한 글을 통해 GCD를 사용한 독자/싱글 writer 패턴을 어떻게 구현하는지 알려 줄것 이다.

우선순위 역전현상

우리는 이 글을 초반부에 NASA의 화성 탐색선이 겪은 병렬처리 관련 문제로 시작하였다. 지금부터 우리는 왜 탐색선이 거의 실패로 돌아 갈 뻔했는지 알아 볼것이며, 왜 우선순위 역전현상이라 불리는 이 어려움을 우리도 똑같이 당할수 있는지 알아 볼 것이다.

우선순위 역전현상은 낮은 우선순위 과업이 높은 우선순위 과업의 실행을 막는 상황를 말하는데, 효율적 진행을 위해 우선순위가 역전되는 것을 말한다. GCD가 파일 I/O를 위한 큐를 포함해 백그라운드 큐를 다양한 우선순위를 가지고 제공되고 있는 지금 꼭 알아두어야 할 중요한 내용이라고 생각한다.

해당 이슈는 높은 우선순위 과업과 낮은 순위 과업이 공유자원을 함께 사용할 때 생길 수 있다. 낮은 우선순위 과업이 공유자원을 사용하기 위해 락을 걸었다면, 높은 우선순위의 과업이 공유자원을 사용하고자 할 때 아무런 거리낌 없이 사용할 수 있도록 락을 일찍 풀어야 한다. 낮은 우선순위 과업이 자원을 점유하고 있으므로 높은 우선순위 과업이 블럭당하면, 그 사이에 큐 안에서 실행 가능한 과업 중 가장 높은 중간정도 되는 우선순위 과업이 높은 우선순위 과업 보다 앞서 실행될 가능성을 열어주게 된다. 이때 중간 우선순위 과업이 낮은 우선순위 과업의 락을 풀는 것을 방해하여 높은 우선순위 과업은 계속 공유자원의 락이 풀리길 기다리게 되여 실행하지 못하게 된다.

Screen Shot 2015-10-13 at 1.35.50 PM

당신의 코드에서는 화성의 탐사선이 재부팅을 하는 것 만큼 심각한 상황을 초래하지 않을 수 있지만 우선순위 역전현상은 꽤 자주 발생하는 현상이다.

특별한 경우가 아니면 서로 다른 우선순위 큐를 사용하지 않길 권유한다. 가끔 높은 우선순위 과업이 낮은 우선순위 과업이 끝날 때 까지 기다리는 현상이 이럴날 수 있기 때문이다. GCD를 사용할 때, 디폴트 우선순위 큐를 사용하길 권한다. 만약 다른 우선순위 규를 사용하게 되면, 상황을 더 악화시킬 수 있다.

하나 이상의 큐를 사용할 때 각기 다른 우선순위를 설정하여 사용하는 것은 좋아 보이지만, 병렬처리 프로그래밍에서 ‘우선순위 역전현상’과 같은 과거의 경험을 통해 보면, 복잡성을 높이게 되며 예상할 수 없는 상황을 만들게 된다. 만약 앞으로 개발을 하면서 높은 우선순위 과업이 예상한 타이밍에 실행되지 않을 때 NASA의 전문 엔지니어도 경험한 ‘우선순위 역전현상’을 떠올리게 될 것이다.

마치며…

우리는 API가 아무리 사용하기 쉽게 보일지라도 해당 글을 통해 병렬처리 프로그래밍의 복잡성을 알려주길 원했고 그에 수반한 문제점을 알아 보길 원했다. 이러한 문제들은 가지고 오는 결과들은 어디가 문제인지 쉽게 파악하기 어렵고, 디버깅하기 조차 매우 어렵다.

반면에 병렬처리 기술은 멀티 코어 컴퓨팅의 이점을 사용할 수 있는 매우 강력한 도구이다. 성공의 열쇠는 병렬처리 모델을 최대한 간단하게 유지하여 락을 필요할 때만 걸라고 하는 것이다.

우리가 추천하는 안전한 방법은 메인 쓰레드에서 데이터를 뽑아내서 백그라운드 큐에서 실제 처리를 하고 결과를 다시 메인 쓰레드에 전달하라는 것이다. 이 방식은 락을 걸지 않게 되하여 실수를 줄일 수 있을 것이다.

댓글 한 개

Leave a Comment.