프로그래밍/iOS

Concurrency Programming Guide (2) - Concurrency and Application Design

병인 2020. 12. 23. 18:00

컴퓨팅 초기에는 컴퓨터가 수행할 수 있는 단위 시간당 최대 작업량이 CPU의 클럭 속도에 의해 결정되었다. 그러나 기술이 발전하고 프로세서 설계가 더욱 간결해지면서 열과 기타 물리적 제약으로 인해 프로세서의 최대 클럭 속도가 제한되기 시작했다. 따라서 칩 제조업체는 칩의 전체 성능을 높이는 다른 방법을 모색했다. 그들이 채택한 솔루션은 각 칩의 프로세서 코어 수를 늘리는 것이었다. 코어 수를 늘리면 CPU 속도를 높이거나 칩 크기나 열 특성을 변경하지 않고도 싱글 칩이 초당 더 많은 명령을 실행할 수 있다. 이제 유일한 문제는 추가 코어를 어떻게 활용할 것인가였다.

 

멀티코어를 활용하기 위해 컴퓨터는 여러 작업을 동시에 수행 할 수 있는 소프트웨어가 필요하다. OS X 또는 iOS와 같은 최신 멀티태스킹 운영 체제의 경우 주어진 시간에 100개 이상의 프로그램이 실행될 수 있으므로, 각 프로그램을 다른 코어에서 예약할 수 있어야 한다. 그러나 이러한 프로그램의 대부분은 실제 처리 시간을 거의 소모하지 않는 시스템 데몬 또는 백그라운드 애플리케이션이다. 그 대신에, 정말로 필요한 것은 개별 애플리케이션이 여분의 코어를 보다 효과적으로 사용할 수 있는 방법이다.

 

애플리케이션에서 멀티코어를 사용하는 기존의 방법은 멀티스레드를 만드는 것이다. 그러나 코어 수가 증가함에 따라 스레드 솔루션에 문제들이 있다. 가장 큰 문제는 스레드 코드가 임의의 코어 개수에 맞게 확장되지 않는다는 것이다. 코어 수만큼 스레드를 생성할 수 없으며 프로그램이 잘 실행될 것으로 기대할 수 없다. 당신은 효율적으로 사용할 수 있는 코어의 수를 알아야 하며, 이는 애플리케이션이 자체적으로 계산하기 어려운 문제이다. 개수를 정확하게 파악하더라도 많은 스레드에 대해 프로그래밍하고, 그것을 효율적으로 실행하고, 서로 간섭하지 않도록 해야 하는 문제가 여전히 남아있다.

 

문제를 요약하자면, 애플리케이션이 다양한 수의 컴퓨터 코어를 활용 할 수 있는 방법이 필요하다. 싱글 애플리케이션에서 수행하는 작업량은 변화하는 시스템 조건에 맞게 동적으로 확장할 수 있어야 한다. 그리고 이러한 코어를 활용하는 데 필요한 작업량이 증가하지 않도록 솔루션이 충분히 단순해야 한다. 좋은 소식은 Apple의 운영 체제가 이러한 모든 문제에 대한 솔루션을 제공한다는 것이다. 이 챕터에서는 이 솔루션을 구성하는 기술과 이를 활용하기 위한 코드를 설계하는 방법을 알아본다.

 

스레드로부터 멀어지기(The Move Away from Threads)

 

스레드는 수년 동안 사용되어 왔으며 계속해서 사용되지만, 확장 가능한 방식으로 멀티 태스크를 실행하는 일반적인 문제를 해결하지 못한다. 스레드를 사용하면 확장 가능한 솔루션을 만드는 부담은 개발자인 너희들의 책임이다. 시스템 조건이 변경되면 동적으로 적합한 개수의 스레드를 생성해야 한다. 스레드의 또 다른 문제는 애플리케이션이 사용하는 스레드를 만들고 유지 관리하는 것과 관련된 대부분의 비용을 스스로 부담한다는 것이다.

 

OS X 및 iOS는 스레드에 의존하는 대신 동시성 문제를 해결하기 위해 비동기식 설계 방식을 채택한다. 비동기 함수는 수년 동안 운영 체제에 존재해왔으며 디스크에서 데이터를 읽는 것과 같이 시간이 오래 걸릴 수 있는 태스크를 시작하는 데 자주 사용된다. 비동기 함수는 호출되면 태스크 실행을 시작하기 위해 백그라운드에서 일부 작업을 수행하지만 실제로 태스크가 완료되기 전에 반환된다. 일반적으로 이 작업에는 백그라운드 스레드를 획득하고 해당 스레드에서 원하는 태스크를 시작한 다음 태스크가 완료되면 호출자에게 알림 (일반적으로 콜백 함수를 통해)을 보내는 작업이 포함된다. 과거에는 수행하려는 태스크에 대해 비동기 함수가 존재하지 않는 경우 고유한 비동기 함수를 작성하고 고유한 스레드를 만들어야 했다. 그러나 이제 OS X와 iOS는 스레드를 직접 관리할 필요 없이 모든 태스크를 비동기적으로 수행할 수 있는 기술을 제공한다.

 

비동기적으로 태스크를 시작하는 기술 중 하나는 GCD(Grand Central Dispatch)가 있다.
GCD는 일반적으로 자신의 애플리케이션에서 사용하는 스레드 관리 코드를 시스템 수준까지 가져온다.
실행하려는 태스크를 정의하고 적절한 Dispatch Queue에 추가하기만 하면 된다.
GCD는 필요한 스레드를 만들고 해당 스레드에서 실행할 태스크를 스케줄링하는 작업을 처리한다.
스레드 관리는 이제 시스템의 일부이기 때문에 GCD는 태스크 관리 및 실행에 대한 전체적인 접근 방식을 제공하여 기존 스레드보다 더 나은 효율성을 제공한다.

 

Operation Queue는 Dispatch Queue와 매우 유사한 역할을 하는 Objective-C 객체이다.
실행할 태스크를 정의한 다음 해당 태스크의 스케줄링 및 실행을 처리하는 Operation Queue에 추가한다.
GCD와 마찬가지로 Operation Queue은 모든 스레드 관리를 처리하여 태스크가 시스템에서 가능한 한 빠르고 효율적으로 실행되도록 한다.

 

다음 섹션에서는 애플리케이션에서 사용할 수 있는 Dispatch Queue, Operation Queue 및 기타 관련 비동기 기술에 대한 자세한 정보를 제공한다.

 

Dispatch Queues

Dispatch Queue는 커스텀 태스크를 실행하기 위한 C 기반 메커니즘이다.
Dispatch Queue는 태스크를 serially 또는 concurrently하게 실행하지만 항상 선입 선출 순서로 실행한다. (즉, Dispatch Queue는 항상 Queue에 추가된 순서로 작업을 큐에서 빼고 시작한다.)
serial Dispatch Queue는 한 번에 하나의 태스크만 실행하며, 이 태스크가 완료될 때까지 기다린 후 새 태스크를 시작한다.
반대로 concurrent Dispatch Queue는 이미 시작된 태스크가 완료 될 때까지 기다리지 않고 가능한 한 많은 태스크를 시작한다.

 

Dispatch queues는 다음과 같은 이점이 있다.

 

  • 직관적이고 간단한 프로그래밍 인터페이스를 제공한다.
  • 자동적이고 전체적인 스레드 풀 관리를 제공한다.
  • 튜닝된 어셈블리의 속도를 제공한다.
  • 스레드 스택이 애플리케이션 메모리에 남아 있지 않기 때문에 훨씬 더 메모리 효율적이다.
  • 로드 중인 커널에 트랩 하지 않는다.
  • 태스크가 비동기 방식으로 전송되어 Dispatch Queue를 교착 상태로 만들 수 없다.
  • 경쟁 상황에서도 우아하게 확장된다.
  • 직렬 serial Dispatch Queue는 잠금 및 기타 동기화 기본 요소에 대한 보다 효율적인 대안을 제공한다.

 

Dispatch Queue에 보내지는 태스크는 함수 또는 블록 객체 내에 캡슐화되어야 한다. 블록 객체는 OS X v10.6 및 iOS 4.0에 도입된 C 언어 기능으로 개념적으로 함수 포인터와 유사하지만 몇 가지 추가 이점이 있다. 일반적으로 블록을 자체적인 어휘적 범위(own lexical scope)에서 정의하는 대신 다른 함수 또는 메서드 내에서 블록을 정의하여 블록이 해당 함수 또는 메서드에서 다른 변수에 액세스 할 수 있도록 한다.
또한 블록은 원래 범위에서 벗어나 힙으로 복사 될 수도 있다. 이는 Dispatch Queue에 보내질 때 발생한다.
이러한 모든 의미론들은 비교적 적은 코드로 매우 동적인 작업을 구현하는 것을 가능하게 한다.

 

Dispatch Queue는 Grand Central Dispatch 기술의 일부이며 C 런타임의 일부이다. 애플리케이션에서 Dispatch Queue를 사용하는 방법에 대한 자세한 내용은 Dispatch Queue 항목을 참조하시오. 블록과 그 이점에 대한 자세한 내용은 블록 프로그래밍 항목을 참조하시오.

 

Dispatch Sources

 

Dispatch Source는 특정 유형의 시스템 이벤트를 비동기적으로 처리하기 위한 C 기반 메커니즘이다. Dispatch Source는 특정 유형의 시스템 이벤트에 대한 정보를 캡슐화하고 해당 이벤트가 발생할 때마다 특정 블록 객체 또는 함수를 Dispatch Queue에 보낸다. Dispatch Source를 사용하여 다음 유형의 시스템 이벤트를 모니터링할 수 있다.

 

  • Timers
  • Signal handlers
  • Descriptor-related events
  • Process-related events
  • Mach port events
  • Custom events that you trigger

 

Dispatch Source는 Grand Central Dispatch 기술의 일부이다. Dispatch Source를 사용하여 애플리케이션에서 이벤트를 수신하는 방법에 대한 자세한 내용은 Dispatch Source 항목을 참조하시오.

 

Operation Queues

 

Operation Queue는 concurrent Dispatch Queue에 해당하는 Cocoa이며 NSOperationQueue 클래스에 의해 구현된다. Dispatch Queue는 항상 선입 선출 순서로 태스크를 실행하지만 Operation Queue는 태스크의 실행 순서를 결정할 때 다른 요소를 고려한다. 이러한 요소 중 가장 중요한 것은 주어진 태스크가 다른 태스크의 완료 여부에 의존하는지이다. 태스크를 정의 할 때 종속성을 구성하고 이러한 종속성을 사용하여 태스크에 대한 복잡한 실행 순서 그래프를 만들 수 있다.

 

Operation Queue에 보내는 태스크는 NSOperation 클래스의 인스턴스여야 한다. operation object는 수행하려는 작업과 이를 수행하는 데 필요한 데이터를 캡슐화하는 Objective-C 객체이다. NSOperation 클래스는 기본적으로 추상 클래스이므로 일반적으로 작업을 수행하기 위해 커스텀 하위 클래스를 정의해야 한다. 그러나 Foundation 프레임워크에는 태스크를 수행하기 위해 생성하고 사용할 수 있는 구체적인 하위 클래스가 포함되어 있다.

 

operation object는 Key-Value Observing (KVO) notification을 생성하며, 이는 태스크의 진행 상황을 모니터링하는 유용할 수 있다. Operation Queue은 항상 operation들을 concurrently 하게 실행하지만, 종속성을 사용하여 필요할 때 직렬로 실행되도록 할 수 있다.

Operation Queue를 사용하는 방법 및 custom operation object를 정의하는 방법에 대한 자세한 내용은 작업 대기열 항목을 참조하시오.

 

비동기 설계 기법(Asynchronous Design Techniques)

 

동시성을 지원하기 위해 코드 재설계를 고려하기 전에 코드 재설계가 필요한지 다시 한번 생각해봐라.
동시성은 main 스레드가 사용자 이벤트에 자유롭게 응답하도록 하여 코드의 응답성을 향상할 수 있다. 더 많은 코어를 활용하여 같은 시간에 더 많은 작업을 수행함으로써 코드의 효율성을 높일 수도 있다.
그러나 동시성은 오버헤드를 추가하고 코드의 전체적인 복잡성을 증가시켜 코드를 작성하고 디버깅하기 어렵게 만든다.

 

동시성은 복잡성을 증가시키기 때문에 product cycle이 끝날 때 애플리케이션에 접목 할 수 있는 기능이 아니다. 올바르게 사용하려면 애플리케이션에서 실행하는 태스크와 이러한 태스크를 실행하는 데 사용되는 데이터 구조를 신중하게 고려해야 한다. 잘못 사용하면 코드가 이전보다 느리게 실행되고 사용자에 대한 응답 속도가 느려질 수 있다. 따라서 design cycle을 시작할 때 몇 가지 목표를 설정하고 취해야 할 접근 방식에 대해 생각하는 데 시간을 할애하는 것이 좋다.

 

애플리케이션마다 요구 사항이 다르고 수행하는 테스크 집합이 다르다. 애플리케이션 및 관련 태스크를 설계하는 방법을 문서로 정확하게 알려주는 것은 불가능하다. 그렇지만 다음 섹션에서는 설계 프로세스 중에 올바른 선택을 하는 데 도움이 되는 몇 가지 지침을 제공하려고 한다.

 

애플리케이션의 예상 동작을 정의하라(Define Your Application’s Expected Behavior)

 

애플리케이션에 동시성을 추가하는 것에 대해 생각하기 전에 항상 애플리케이션의 올바른 동작으로 간주되는 것을 정의하는 것으로 시작해야 한다. 애플리케이션의 예상 동작을 이해하면 나중에 설계를 검증할 수 있기 때문이다. 또한 동시성을 도입하여 얻을 수 있는 성능 이점을 예상해야 한다.

 

가장 먼저해야 할 일은 애플리케이션이 수행하는 태스크와 각 태스크와 관련된 객체 또는 데이터 구조를 열거하는 것이다. 처음에는 사용자가 메뉴 항목을 선택하거나 버튼을 클릭할 때 수행되는 테스크로 시작할 수 있다. 이러한 테스크는 개별 동작을 제공하며 시작 및 끝 점이 잘 정의되어 있다. 또한 타이머 기반 테스크와 같이 사용자와의 상호 작용 없이 애플리케이션에서 수행할 수 있는 다른 유형의 태스크도 열거해야 한다.

 

높은 수준의 테스크 목록을 얻은 후에는 각 테스크를 성공적으로 완료하기 위해 수행해야 하는 단계로 더 세분화를 하라. 이 수준에서는 데이터 구조 및 객체에 대해 수행해야 하는 변경 사항과 이러한 변경 사항이 애플리케이션의 전체 상태에 어떤 영향을 미치는지에 대해 주로 고려해야 한다. 또한 객체와 데이터 구조 간의 종속성도 확인해야 한다. 예를 들어 테스크가 객체 배열을 동일하게 변경하는 경우 한 객체의 변경 사항이 다른 객체에 영향을 주는지 여부에 주목해야 한다. 객체가 서로 독립적으로 변경될 수 있는 경우 동시에 변경을 수행할 수 있기 때문이다.

 

실행 가능한 작업 단위 추출(Factor Out Executable Units of Work)

 

애플리케이션의 테스크에 대한 이해를 바탕으로 코드에서 동시성의 이점을 얻을 수 있는 위치를 이미 파악해야 한다. 태스크 순서를 변경했을 때 하나 이상의 단계에서 결과가 바뀐다면, 해당 단계는 직렬적으로 수행해야 한다. 그러나 순서를 변경해도 결과에 영향을 주지 않는 경우 이러한 단계는 동시에 수행하는 것이 좋다. 두 경우 모두 수행할 단계를 나타내는 실행 가능한 작업 단위를 정의한다. 이 작업 단위는 블록 또는 operation object를 사용하여 캡슐화하고 적절한 큐로 Dispatch를 한다.

 

파악한 각각의 실행 가능한 작업 단위에 대해 최소한 초기에 수행되는 작업의 양에 대해 크게 걱정하지는 말아라. 스레드를 변경하는 데는 항상 비용이 들지만 Dispatch Queue와 Operation Queue의 장점 중 하나는 대부분의 경우 이러한 비용이 기존 스레드의 비용보다 훨씬 적다는 것이다. 따라서 스레드를 사용하는 것보다 큐를 사용하여 더 작은 작업 단위를 더 효율적으로 실행할 수 있다.
물론 항상 실제 성능을 측정하고 필요에 따라 테스크 크기를 조정해야 하지만 처음에는 너무 작은 태스크로 간주해서는 안된다.

 

필요한 대기열 식별(Identify the Queues You Need)

 

테스크가 별개의 작업 단위로 분할되고 블록 객체 또는 operation object를 사용해 캡슐화 되었으므로 해당 코드를 실행하는 데 사용할 큐를 정의해야 한다. 태스크가 주어졌을 때 생성한 블록 또는 operation object, 태스크를 올바르게 수행하기 위해 실행해야 하는 순서를 검사한다.

 

블록을 사용하여 작업을 테스크를 구현 한 경우 serial 또는 concurrent Dispatch Queue에 블록을 추가할 수 있다. 특정한 오더가 필요한 경우 항상 직렬 serial Dispatch Queue에 블록을 추가해야 한다. 특정한 오더가 필요하지 않은 경우 블록을 concurrent Dispatch Queue에 추가하거나 필요에 따라 여러 다른 Dispatch Queue에 추가할 수 있다.

 

operation object를 사용하여 테스크를 구현한 경우 큐 선택은 객체 구성보다 재미가 없는 경우가 많다. operation object를 직렬로 수행하려면 관련 객체 간의 종속성을 구성해야 한다. 종속성은 종속된 객체의 operation을 완료할 때까지 operation이 실행되지 않도록 한다.

 

효율성 향상을 위한 팁(Tips for Improving Efficiency)

 

단순히 코드를 더 작은 테스크로 팩터링 하고 큐에 추가하는 것 외에도 큐를 사용하여 코드의 전반적인 효율성을 향상할 수 있는 다른 방법이 있다.

 

  • 메모리 사용량이 원인인 경우 테스크 내에서 직접 값을 계산하는 것을 고려해라. 애플리케이션이 이미 메모리에 바인딩되어 있는 경우 직접 값을 계산하는 것이 주 메모리에서 캐시 된 값을 로드하는 것보다 빠를 수 있다. 계산 값은 주 메모리보다 훨씬 빠른 지정된 프로세서 코어의 레지스터와 캐시를 직접 사용한다. 물론, 테스트 결과 이것이 성능이 좋다고 판단되는 경우에만 수행해야 한다.
  • serial task를 미리 찾고 concurrent로 만들기 위해 수행할 수 있는 태스크를 수행한다. 태스크가 일부 공유 리소스에 의존하기 때문에 직렬적으로 실행해야 하는 경우 해당 공유 리소스를 제거하도록 아키텍처를 변경하는 것이 좋다. 리소스가 필요한 각 클라이언트에 대해 리소스 복사본을 만들거나 리소스를 모두 제거하는 것을 고려할 수 있다.
  • 잠금(Lock)을 사용하지 말아라. Dispatch Queue and Operation Queue에서 제공하는 지원은 대부분의 상황에서 잠금을 불필요하게 만든다. 일부 공유 리소스를 보호하기 위해 잠금을 사용하는 대신 올바른 순서로 테스크를 실행하기 위해 serial queue를 지정 (또는 operation object의 종속성 사용) 한다.
  • 가능할 때마다 시스템 프레임워크를 사용하라. 동시성을 달성하는 가장 좋은 방법은 시스템 프레임워크에 내장된 동시성을 활용하는 것이다. 많은 프레임워크는 내부적으로 스레드 및 기타 기술을 사용하여 동시 동작을 구현한다. 테스크를 정의할 때 기존 프레임워크에서 원하는 테스크를 정확히 수행하고 동시성을 수행하는 기능 또는 메서드를 정의하는지 확인하라. API를 사용하면 노력을 절약할 수 있으며 가능한 최대 동시성을 제공할 가능성이 더 크다.

 

성능 영향(Performance Implications)

 

더 많은 코드를 동시에 쉽게 실행할 수 있도록 Operation Queue, Dispatch Queue 및 Dispatch Source가 제공된다. 그러나 이러한 기술은 애플리케이션의 효율성 또는 응답성을 향상시키지는 못한다. 여전히 사용자의 요구 사항에 효과적이며 애플리케이션의 다른 리소스에 과도한 부담을 주지 않는 방식으로 큐를 사용하는 것은 당신의 책임이다. 예를 들어 10,000 개의 operation object를 만들어 Operation Queue에 보낼 수 있지만 그렇게 하면 애플리케이션에 잠재적으로 중요한 크기의 메모리를 할당하여 페이징 및 성능 저하로 이어질 수 있다.

 

큐를 사용하든 스레드를 사용하든 상관없이 코드에 동시성을 도입하기 전에 항상 애플리케이션의 현재 성능을 반영하는 일련의 메트릭스 기준을 수집해야 한다. 변경 사항을 도입 한 후에는 추가 메트릭스를 수집하고 이를 기준과 비교하여 애플리케이션의 전반적인 효율성이 향상되었는지 확인해야 한다. 동시성의 도입으로 애플리케이션의 효율성이나 응답성이 떨어지면 사용 가능한 성능 도구를 사용하여 잠재적인 원인을 확인해야 한다.

 

동시성 및 기타 기술(Concurrency and Other Technologies)

 

코드를 모듈형 테스크로 팩터링 하는 것은 애플리케이션의 동시성을 개선하는데 가장 좋은 방법이다. 그러나 이러한 설계 접근 방식은 모든 경우에 모든 애플리케이션의 요구 사항을 충족하지 못할 수 있다. 테스크 따라 애플리케이션의 전반적인 동시성을 추가로 개선할 수 있는 다른 옵션이 있을 수 있다. 이 섹션에서는 설계의 일부로 사용할 다른 기술에 대해 간략하게 설명한다.

 

OpenCL and Concurrency

 

OS X에서 OpenCL (Open Computing Language)은 컴퓨터의 그래픽 프로세서에서 범용 연산을 수행하기 위한 표준 기반 기술이다. OpenCL은 대규모 데이터 세트에 적용할 컴퓨팅 세트가 올바르게 정의된 경우 사용하기에 좋은 기술이다. 예를 들어 OpenCL을 사용하여 이미지의 픽셀에 대한 필터 계산을 수행하거나 한 번에 여러 값에 대한 복잡한 수학 계산을 수행하는 데 사용할 수 있다. 즉, OpenCL은 데이터가 병렬로 작동할 수 있는 문제 세트에 더 적합하다.

 

OpenCL은 대규모 데이터 병렬 작업을 수행하는 데 적합하지만, 보다 일반적인 계산에는 적합하지 않다. GPU에서 작동 할 수 있도록 데이터와 필요한 작업 커널을 모두 준비하고 그래픽 카드로 전송하는 데는 상당한 자원이 필요하다. 마찬가지로 OpenCL에 의해 생성된 결과를 검색하는 데는 상당한 자원이 필요하다. 결과적으로 시스템과 상호 작용하는 모든 작업은 일반적으로 OpenCL과 함께 사용하지 않는 것이 좋다. 예를 들어 파일 또는 네트워크 스트림의 데이터를 처리하는데 OpenCL을 사용하지 않을 수 있다. 대신 OpenCL을 사용하여 수행하는 작업은 그래픽 프로세서로 전송되고 독립적으로 계산될 수 있도록 훨씬 더 독립적이어야 한다.

 

스레드를 사용할 시기 (When to Use Threads)

 

Operation Queue와 Dispatch Queue는 테스크를 동시에 수행하는 데 선호되는 방법이지만 만병 통치약은 아니다. 애플리케이션에 따라 커스텀 스레드를 만들어야 하는 경우가 있을 수 있다. 커스텀 스레드를 생성하는 경우 가능한 한 적은 수의 스레드를 직접 생성해야 하며 다른 방법으로 구현할 수 없는 특정 테스크에만 해당 스레드를 사용해야 한다.

 

스레드는 실시간으로 실행되어야 하는 코드를 구현하는 좋은 방법이다. Dispatch Queue는 가능한 한 빨리 태스크를 실행하려고 시도하지만 실시간 제약 조건을 해결하지는 못한다. 백그라운드에서 실행되는 코드에서 더 예측 가능한 동작이 필요한 경우 스레드가 더 나은 대안을 제공할 수 있다.

 

모든 스레드 프로그래밍과 마찬가지로 스레드를 항상 신중하게 사용해야 하며 반드시 필요한 경우에만 사용해야 한다.

 

[원문]

 

Concurrency and Application Design

Concurrency and Application Design In the early days of computing, the maximum amount of work per unit of time that a computer could perform was determined by the clock speed of the CPU. But as technology advanced and processor designs became more compact,

developer.apple.com