티스토리 뷰

반응형

이번 글에서는 동시성 프로그래밍에서 스레드를 어떻게 쉽게 사용할지에 대해 알아보자

 

Apple Developer Documents - Migrating Away from Threads

Migrating Away from Threads

Grand Central Dispatch와 작업 객체를 사용에 효율적이게 기존의 스레드 코드를 수정하는 방법에는 여러 가지가 있다. 대부분의 경우 스레드를 사용하지 않는 것은 불가능하지만 스레드를 대체하도록 구현한다면 성능이 많이 향상될 수 있다. 특히 스레드 대신 dispatch queue, operation queue를 사용하면 몇 가지 장점이 있다.

 

  • 프로그램의 메모리 공간에 스레드 스택을 저장하기 위해 필요한 메모리를 줄인다.
  • 스레드를 작성하고 구성하는 데 필요한 코드를 제거한다.
  • 스레드 작업을 관리하고 예약하는데 필요한 코드를 제거한다.
  • 작성해야하는 코드를 단순화한다.

이번 글에서는 기존 스레드 기반 코드를 dispatch queue, operation queue를 사용하여 동일한 동작을 수행하도록 하는 방법과 몇 가지 지침을 알아보자.

Replacing Threads with Dispatch Queues

스레드를 dispatch queue로 대체하는 방법을 이해하려면 프로그램이 스레드를 사용하는 방법들을 알아야 한다.

 

  • Single task threads
    하나의 task를 수행할 스레드를 만들고 task가 끝나면 스레드를 해제한다.
  • Worker threads
    어떠한 작업을 위해 하나 이상의 worker 스레드를 만들고 각 스레드에 작업을 주기적으로 디스 패치한다.
  • Thread pools
    일반적인 스레드 풀을 작성하고 각각에 대해 반복문을 설정한다. 수행할 작업이 있으면 풀에서 스레드를 가져와서 작업을 전달하고 만약 사용 가능한 스레드가 없다면 작업을 대기하고 사용 가능해질 때까지 기다린다.

위의 방법들이 다른 기술처럼 보일지는 모르지만 사실은 방법만 다르지 원칙은 같다. 각각의 경우 스레드는 프로그램의 작업을 실행하는 데 사용되는 점은 동일하다. 차이점은 스레드 및 작업이 추가되는 queue의 관리방법이다. Dispatch queue, Operation queue를 사용하면 모든 스레드 및 스레드 통신 코드를 제거하고 수행하려는 작업에만 집중할 수 있다.

 

위의 스레드 모델들을 큐를 사용하여 대체하려는 경우 프로그램이 수행하는 작업의 유형을 잘 알아야 한다. 스레드를 대체하기 위해서는 스레드에 작업을 제출하는 것이 아닌 작업 객체나 블록을 캡슐화하고 적절한 큐에 넣어줘야 한다. lock을 사용하지 않는 작업의 경우 다음과 같이 스레드를 대체할 수 있다.

 

  • Single task thread의 경우 task를 작업 객체나 블록으로 캡슐화하여 병렬 큐에 추가한다.
  • Worker thread의 경우 직렬 큐를 사용할 것인지 병렬 큐를 사용할 것인지 결정해야 한다. 만약 worker thread를 사용하여 작업들의 실행 순서에 맞게 실행하려는 경우엔 직렬 큐를 사용하고 종속성 없이 임의의 작업을 실행하려는 경우엔 병렬 큐를 사용하면 된다.
  • Thread pool의 경우 task를 작업 객체나 블록으로 캡슐화하여 병렬 큐에 디스 패치한다.

물론 이와 같은 간단한 교체방법이 모든 경우에 사용 가능한 것은 아니다. 위와 같은 교체방법을 사용할 수 없는 경우가 실행 중인 작업이 공유 리소스를 위해 경쟁하고 있는 상황이다. 이 문제의 이상적인 해결방법은 경쟁을 제거하거나 최소화하는 것이다. 공유 리소스에 대한 상호 의존성을 제거하기 위해서는 코드를 다시 작성하거나 아키텍처를 재구성하는 것이 가장 좋다. 하지만 그렇게 할 수 없는 경우 여전히 queue를 사용할 수 있는 방법은 있다. 큐를 사용하면 가장 좋은 점 중 하나는 예측 가능하도록 실행할 수 있다는 것이다. 여기서 예측 가능하다는 말은 lock과 다른 기법들 말고 코드를 실행할 방법이 있다는 것이다. lock을 사용하는 대신 queue를 사용하여 동일하게 작업을 수행하는 방법은 다음과 같다.

 

  • 특정 순서로 실행해야 하는 작업의 경우 직렬 dispatch queue에 작업을 추가한다. Operation queue를 사용하고 싶다면 작업들 간에 종속성을 사용하여 순서를 지켜줘야 한다.
  • 현재 lock을 사용하여 공유 리소스를 보호하는 경우 해당 리소스를 수정하는 작업은 직렬 큐에 추가한다. 그런 뒤 직렬 큐는 기존의 lock을 synchronization mechanism으로 바꾼다. 이러한 방법에 대한 내용은 잠시 후에 Eliminating Lock-Based Code 섹션을 참고하자.
  • 백그라운드 작업이 완료될 때까지 스레드 조인을 기다리는 상태라면 dispatch group를 사용하는 것을 고려해보자. 뿐만 아니라 NSBlockOperaion 객체나 작업 간 종속성을 사용하면 유사한 작업 그룹을 만들어 수행할 수 있다. 실행 중인 작업 그룹을 추적하는 방법은 잠시 후에 Replacing Thread Joins 섹션에서 알아보자
  • 생산자-소비자 알고리즘을 사용하여 유한한 자원을 관리하는 경우 잠시후에 알아볼 Producer-Consumer Implementation 섹션에 표시된 구현으로 변경을 고려해보자.
  • 스레드를 사용하여 디스크립터에서 읽고 쓰거나 파일 작업을 모니터 하는 경우 잠시 후에 나올 Dispatch Sources 섹션을 참고하여 디스패치 소스를 사용하자.

큐로 스레드를 반드시 교체할 수는 없다. 큐의 대기시간에 문제가 없다면 큐에서 제공하는 비동기식 프로그래밍 모델이 적합하다. 큐가 큐 내부의 작업들의 우선순위를 구성하긴 하지만 큐가 여러 개 있다면 반드시 우선순위에 따라 실행되지도 않는다. 따라서 오디오 및 비디오 재생과 같이 대기 시간을 최소화해야 하는 경우엔 스레드가 여전히 더 적합할 수 있다.

Eliminating Lock-Based Code

스레드 코드의 경우 lock은 스레드 간에 공유되는 리소스에 대한 접근을 동기화하는 방법이다. 하지만 이러한 lock은 비용이 발생하게 되는데 이는 성능 저하로 연결되는 문제이다. 이러한 문제는 비경쟁 상태에서도 발생한다. 또한 경쟁상태에서 하나이상의 스레드가 lock의 해제를 기다리며 차단될 가능성도 있다.

 

이러한 lock 기반 코드를 queue로 바꾸면 위의 문제들을 제거하고 코드도 간소화된다. lock을 사용하여 리소스를 보호하는 대신 큐를 만들어 해당 리소스에 접근하는 작업들을 순차적으로 진행할 수 있다. 큐는 lock의 부정적인 면을 발생시키지 않는데 예를 들면 작업을 큐에 추가하는 것은 mutax를 얻기 위해 커널에 트래핑할 필요가 없다.

 

작업을 큐에 넣을 때 가장 먼저 결정할 것은 동기식 작업인지 비동기식 작업인지에 대한 부분이다. 작업을 비동기식으로 추가하면 작업이 수행되는 동안 현재 스레드가 계속 실행될 수 있다. 반대로 동기식으로 작업을 추가하게 되면 현재 스레드의 task가 완료될 때까지 스레드가 차단된다. 웬만하면 비동기식으로 추가하는 것이 유리하지만 두 옵션 모두 사용되기는 한다.

 

다음 섹션에서는 lock 기반 코드를 queue 기반 코드로 바꾸는 방법을 보여준다.

Implementing an Asynchronous Lock

비동기식 lock은 공유 리소스를 수정하려는 코드를 차단하지 않고 보호하는 방법이다. 이러한 비동기식 lock은 코드가 실행하는 다른 작업의 부작용으로 자료 구조를 수정해야 할 때 사용할 수 있다. 기존의 스레드를 사용하면 일반적으로 공유 리소스의 접근을 lock으로 차단하고 필요한 수정을 한 뒤 lock을 해제하여 남은 작업을 수행할 것이다. 하지만 dispatch queue를 사용하면 코드는 수정이 완료될 때까지 기다리지 않고 비동기식으로 실행할 수 있다.

 

dispatch_async(obj->serial_queue, ^{
   // Critical section
});

위의 코드는 비동기식 lock 구현의 예를 보여준다. 이 예에서 리소스를 보호하는 것을 직렬 dispatch queue에서 정의한다. 호출 코드는 리소스를 수정해야 하는 작업을 큐에 추가하는데, 이는 큐가 순차적으로 실행되기 때문에 리소스에 대한 변경도 추가된 순서대로 이루어진다. 하지만 작업이 비동기적으로 실행되었기 때문에 호출 스레드는 차단되지 않는다.

Executing Critical Sections Synchronously

작업이 완료될 때까지 현재 코드를 실행할 수 없다면 dispatch_sync 함수를 사용하여 작업을 동기식으로 추가할 수 있다. 이 함수는 작업을 dispatch queue에 추가한 뒤 다음 작업이 완료될 때까지 현재 스레드를 차단한다. Dispatch queue 자체는 직렬, 병렬 구성될 수 있지만 이 함수는 현재 스레드를 차단하기 때문에 필요할 때만 사용해야 한다.

 

dispatch_sync(my_queue, ^{
   // Critical section
});

위의 코드는 코드의 중요한 부분을 dispatch_sync를 사용하여 wrapping 하는 방법이다.

 

이미 공유 리소스를 보호하기 위해 직렬 큐를 사용하고 있다면 동기식으로 디스패치 한 것은 비동기식으로 디스패치 한 경우와 다르게 공유 리소스가 보호되지 않는다. 그럼에도 불구하고 동기식으로 디스패치 하는 유일한 이유는 중요한 작업이 완료될 때까지 현재 코드가 계속 진행되지 않도록 하기 위해서이다. 예를 들어 공유 리소스에서 바로 값을 찾아서 사용하려면 동기적으로 디스패치 해야 한다는 말이다. 이와 다르게 중요한 작업이 완료될 때까지 기다릴 필요가 없거나 추가적인 작업들이 동일한 직렬 큐에 간단히 추가될 수 있는 경우엔 비동기식으로 큐에 추가하는 것이 좋다.

Improving on Loop Code

만약 코드에 반복문이 있고 매 반복마다 수행되는 작업이 다른 반복에서 수행되는 작업과 독립적일 경우 dispatch_apply, dispatch_apply_f 함수를 사용하여 해당 반복문을 다시 구현하는 것을 고려해보자. 이 함수들은 반복문의 각 반복을 처리하기 위해 dispatch queue에 별도로 작업을 추가한다. Concurrent queue와 함께 사용하면 반복문의 여러 반복을 동시에 수행할 수 있다.

 

dispatch_apply, dispatch_apply_f 함수는 모든 반복이 완료될 때까지 현재 실행 스레드를 차단하는 동기식 함수 호출이다. Concurrent Queue에 작업을 추가하게 되면 반복의 순서는 보장될 수 없다. 각 반복을 실행하는 스레드는 주어진 반복이 차단되고 종료될 수 있기 때문에 각 반복마다 사용되는 블록 객체, 함수는 매번 새로 입력해줘야 한다.

queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(count, queue, ^(size_t i) {
   printf("%u\n", i);
});

위의 코드는 for 반복문을 dispatch 기반으로 바꾸는 방법이다. dispatch_apply, dispatch_apply_f에 전달되는 블록, 함수는 현재 몇 번 반복되었는지에 대한 정수 값을 가져야 한다. 위의 예제에선 단순히 반복 횟수를 콘솔에 인쇄한다.

 

위의 예제는 간단하지만 dispatch queue를 사용하여 간단한 반복문을 교체하는 방법을 보여준다. 반복이 주된 코드에서 이러한 방법은 성능을 향상할 수 있는 좋은 방법이긴 하지만 신중하게 사용해야 한다. Dispatch queue의 오버헤드는 낮지만 반복마다 스레드에 스케쥴링하는 비용은 여전히 발생한다. 그러므로 반복문이 실행되기에 충분한 자원을 가지고 있는지 확인해야 한다. 즉 얼마나 많은 반복을 한 번에 수행할 수 있는지 성능 도구를 사용하여 측정해야 한다.

 

각각의 반복에서 작업량을 늘리는 간단한 방법은 striding을 사용하는 것이다. Striding을 사용하여 코드를 원래 반복이 두 번 이상 반복되도록 수정한다. 그런 뒤 dispatch_apply 함수에 지정한 개수 값을 비례적으로 줄이면 된다.

int stride = 137;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

dispatch_apply(count / stride, queue, ^(size_t idx){
    size_t j = idx * stride;
    size_t j_stop = j + stride;
    do {
       printf("%u\n", (unsigned int)j++);
    }while (j < j_stop);
});

size_t i;
for (i = count - (count % stride); i < count; i++)
   printf("%u\n", (unsigned int)i);

위의 코드는 아까 예제로 든 반복문을 stride로 구현한 것이다. 위의 코드에서 블록은 stride 값과 동일한 횟수로 (위의 코드에서는 137) printf 함수를 호출한다. 총 반복 횟수를 stride로 나누면 나머지가 남는데 이는 in-line으로 수행된다.

 

이렇게 stride를 사용하면 성능적인 부분은 확실하게 이득이 있다. 특히 stride에 비해 원래의 반복 횟수가 더 많은 경우에 이득이 발생한다. 적은 수의 블록을 동시에 디스패치 하라는 뜻은 블록을 디스패치 하는 시간보다 블록을 실행하는데 더 많은 시간이 쓰인다는 것을 의미한다. 이러한 부분 역시 코드에서 가장 효율적인 방법을 찾아 적합한 것을 사용하면 된다.

Replacing Thread Joins

Thread join을 사용하거나 하나 이상의 스레드를 사용할 때 다른 스레드들이 완료될 때까지 현재 스레드를 대기시킬 수 있다. 스레드 조인을 구현하기 위해서는 부모 스레드가 자식 스레드를 조인 가능하게 만들어야 한다. 부모 스레드가 자식 스레드가 완료되지 않고서는 진행될 수 없다면 부모와 자식을 조인한다. 이 과정은 자식 스레드가 작업을 완료할 때까지 부모 스레드를 차단한다. 그런 뒤 자식이 완료되면 부모 스레드는 해당 결과를 가지고 다시 작업을 진행할 수 있다. 만약 부모 스레드가 여러 개의 자식 스레드와 조인해야 하는 경우에는 한 번에 하나씩 수행한다.

 

Dispatch group은 스레드 조인과 비슷하지만 몇 가지 추가적인 장점이 있다. 스레드 조인과 마찬가지로 디스패치 그룹은 하나 이상의 자식 작업이 완료될 때까지 스레드를 차단한다. 하지만 스레드 조인과는 다르게 디스패치 그룹은 모든 자식 스레드의 작업이 완료되는 것을 대기한다. 또한 디스패치 그룹은 dispatch queue를 사용하기 때문에 매우 효율적이다.

 

이러한 디스패치 그룹을 사용하여 스레드 조인과 같은 작업을 하기 위해서는 다음을 수행하면 된다.

 

  1. dispatch_group_create 함수를 사용하여 새로운 디스패치 그룹을 만든다.
  2. dispatch_group_async, dispatch_group_async_f 함수를 사용하여 그룹에 작업을 추가한다. 그룹에 추가되는 작업은 스레드 조인도 가능한 작업들이다.
  3. 현재 스레드가 더 이상 진행할 수 없다면 dispatch_group_wait 함수를 호출하여 그룹을 기다린다. 이 함수는 그룹의 모든 작업이 완료될 때까지 현재 스레드를 차단한다.

작업 객체를 사용하여 task를 구현하는 경우 종속성을 사용하여 스레드 조인과 같은 결과를 만들 수도 있다. 부모 스레드가 하나 이상의 자식 스레드의 작업이 완료되는 것을 기다리는 것이 아닌 부모 코드를 작업 객체로 옮기는 것이다. 그런 뒤 부모 작업과 자식 작업의 개념으로 종속성을 설정하면 자식 작업들이 모두 완료될 때까지 부모 작업은 실행되지 않는다.

Changing Producer-Consumer Implementations

Producer-Consumer 모델을 사용하면 한정된 수의 동적으로 생성된 리소스들을 관리할 수 있다. producer가 새로운 리소스를 만드는 동안 consumer는 리소스가 준비되는 것을 대기하다 준비가 되면 사용하면 된다. 이러한 모델의 일반적인 메커니즘은 조건과 semaphore(세마포어)이다.

 

조건을 사용하는 생산자 스레드는 다음을 수행한다.

 

  • pthread_mutax_lock을 사용하여 조건과 관련된 mutax를 잠근다.
  • 소비할 리소스 또는 작업을 생산한다.
  • pthread_cond_signal을 사용하여 사용할 것이 있다고 조건 변수에 알린다.
  • pthread_mutax_unlock을 사용하여 뮤텍스를 잠금 해제한다.

그다음 소비자 스레드는 다음을 수행한다.

 

  • pthread_mutax_lock를 사용하여 조건과 관련된 뮤텍스를 잠근다.
  • 다음은 while 반복문에서 구현해야 한다.
    • 해야 할 작업이 무엇인지 확인한다.
    • 작업이나 리소스가 없다면 pthread_cond_wait 함수를 호출하여 생길 때까지 현재 스레드를 차단한다.
  • 생산자가 제공한 작업, 리소스를 얻는다.
  • pthread_mutex_unlock을 사용하여 뮤텍스 잠금을 해제한다.
  • 작업을 처리한다.

Dispatch queue를 사용하면 이러한 구현을 한 번의 호출로 단순화할 수 있다.

dispatch_async(queue, ^{
   // Process a work item.
});

 

생산자가 완료해야 할 작업은 큐에 작업을 추가하고 큐가 항목을 처리하도록 하는 것이다. 위의 코드에서 변경되는 유일한 부분은 큐의 타입이다. 이는 직렬인지 병렬인지에 관한 것으로 작업을 특정 순서로 실행해야 하는 경우엔 직렬 큐를 동시에 수행하려는 경우엔 병렬 큐로 정의해주면 된다.

Replacing Semaphore Code

공유 리소스에 대한 접근을 세마포어를 사용해서 제한하는 경우 dispatch semaphore의 사용을 고려해봐야 한다. 보통의 세마포어는 항상 세마포어를 테스트하기 위해 커널을 호출하는데 dispatch 세마포어는 사용자 공간에서 세마포어 상태를 테스트하고 만약 실패할 경우엔 스레드를 차단해야 하기 때문에 커널을 호출한다. 이러한 이유로 dispatch 세마포어가 기존의 세마포어보단 훨씬 빠르다. 기능적으로는 모두 동일하다. 이러한 dispatch 세마포어를 사용하는 방법은 Using Dispatch Semaphores to Regulate the Use of Finite Resources에서 알아보자.

Replacing Run-Loop Code

Run-loop를 사용하여 실행 중인 작업을 관리하는 경우 큐를 구현한 뒤 계속 유지하는 것이 훨씬 간단한 것을 알 수 있다. Run-loop를 직접 정의하려면 기본 스레드와 run-loop 자체를 모두 정의해야 한다. Run-loop 코드는 하나 이상의 루프 소스를 설정하고 이벤트를 처리하기 위해 콜백을 작성하는 것으로 구성된다. 직렬 큐를 사용하면 이러한 작업 대신 큐를 생성하고 작업을 디스패치 하는 것으로 대체할 수 있다. 따라서 이러한 귀찮은 작업을 단 한 줄로 바꿀 수 있다는 말이다.

dispatch_queue_t myNewRunLoop = dispatch_queue_create("com.apple.MyQueue", NULL);

위의 코드가 run-loop에 필요한 정의를 큐로 구현하는 방법이다.

 

큐에 추가된 작업은 자동으로 실행되기 때문에 이를 위해 추가 코드는 필요하지 않다. 따라서 스레드를 만들거나 구성할 필요도 없고 run-loop를 만들거나 연결할 필요도 없다. 또한 작업을 큐에 추가하여 큐에서 새로운 타입의 작업도 수행할 수 있다. 이러한 것을 Run-loop에서는 run loop source를 수정하거나 새로운 타입을 위해 run loop source를 새로 작성해야 한다.

 

Run-loop의 일반적인 구성 중 하나는 네트워크 소켓에서 도착하는 비동기적인 데이터를 처리하는 것이다. 이러한 것을 Run-loop 말고 dispatch source를 사용하여 원하는 큐에 연결할 수 있다. Dispatch source는 기존의 run loop source보다 더 많은 옵션을 제공한다. 이러한 dispatch source에 관한 내용은 Dispatch Sources에서 더 알아보자.

Compatibility with POSIZ Threads

Grand Central Dispatch는 개발자가 제공한 task와 task가 수행될 스레드 사이의 관계를 관리하기 때문에 일반적으로 POSIX 스레드 루틴을 호출하지 않아야 한다. 어떤 이유로 POSIX 스레드 루틴을 호출해야 하는 경우 어떤 루틴을 호출하는지 주의해야 한다. 이 섹션에서는 어떤 루틴이 호출하기에 안전하고 대기 중인 작업에서 호출하기에 안전하지 않은지에 대해 알아보자

 

일반적으로 프로그램은 자체적으로 만들지 않은 객체나 데이터 구조를 삭제하거나 변경하면 안 된다. 따라서 dispatch queue를 사용하여 실행되는 블록 객체는 다음 함수를 호출하면 안된다.

 

  • pthread_detach
  • pthread_cancel
  • pthread_join
  • pthread_kill
  • pthread_exit

작업이 실행되는 동안에는 스레드 상태를 수정해도 괜찮지만 끝나게 되면 스레드를 원래 상태로 되돌려야한다. 따라서 스레드를 다시 원래상태로 되돌린다는 보장이 있다면 다음 함수를 호출해도 괜찮다.

 

  • pthread_setcancelstate
  • pthread_setcanceltype
  • pthread_setchedparam
  • pthread_sigmask
  • pthread_setspecific

주어진 블록을 실행하는 데 사용되는 기본 스레드는 invocation에 의해 바뀔 수 있다. 결과적으로 프로그램은 호출 간에 예측 가능한 결과를 반환하는 다음 함수에 의존하면 안 된다.

 

  • pthread_self
  • pthread_getscedparam
  • pthread_get_stacksize_np
  • pthread_get_stackaddr_np
  • pthread_mach_thread_np
  • pthread_from_mach_thread_np
  • pthread_getspecific

블록은 언어 수준의 예외를 찾고 억제해야 한다. 블록이 실행하는 동안 발생한 다른 오류는 블록에서 처리하거나 프로그램의 다른 부분에 알리는 데 사용해야 한다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함