동시성은 여러가지 일이 동시에 일어나는 개념이다. 멀티코어 CPU의 확산과 프로세서의 많은 수의 코어가 증가할 것이라는 인식에 따라 소프트웨어 개발자들은 이러한 이점을 이용할 방법이 필요하다.
비록 iOS
와 OS X
와 같은 운영체제들이 다양한 프로그램들을 병렬적으로 작동하지만 대다수 프로그램들은 백그라운드에서 작동하고 지속시간이 적은 작업을 작동시킨다. 사용자의 주목을 끌고 컴퓨터가 바쁜상태를 유지하는 것을 foreground상태라고 부른다. 만약 애플리케이션이 많은 일을 하지만 이용가능한 코어의 부분만 사용된다면 나머지 리소스들은 낭비가 될 것이다.
과거에는 애플리케이션에 동시성을 도입하려면 하나 이상의 스레드를 생성을 해야했다. 스레드를 만드는 일은 저수준의 tool이기 떄문에 굉장히 어려운 작업이었다. 현재 시스템과 하드웨어 기반에 따라 최적의 스레드 수가 변한다는 것을 고려할 때 올바른 솔루션을 구현하는 것이 힘든 부분이었다.
그리고 스레드와 함께 사용되는 동기화 메커니즘을 애플리케이션에 추가하는 것은 성능향상에 대한 보장없이 복잡성을 증가시키는 일이다.
OS X
와 iOS
둘 다 스레드를 직접생성시키는 것이 아닌 동시에 작업을 실행하는 것에 대한 비동기접근법을 채택했다. 애플리케이션은 그저 정의된 작업을 알고 시스템에게 수행을 시키기만 하면 된다. 시스템이 스레드를 관리함으로써 애플리케이션은 raw threads
의 확장레벨을 얻게되어 더 단순하고 효율적인 프로그래밍 모델을 얻을 수 있게 된다.
thread
: 분리된 코드의 실행경로를 가리킨다. 이 구현은 POSIX threads API
에 기반해서 구현이 되었다.process
: 여러개의 스레드를 포괄할 수 있는 실행중인 파일task
: 수행될 필요가 있는 작업의 추상적인 개념초기 컴퓨팅에서는 컴퓨터가 수행할 수 있는 단위당 최대 작업양은 CPU 클락에 의해 결정되었다.
그러나 기술이 발전하고 설계가 compact해지면서 열과 다른 물리적 제약들은 프로세서의 최대클락속도를 제한하기 시작했다. 그래서 칩 제조회사는 칩의 성능을 높일 수 있는 다른 방법들을 찾았다.
그 해결책은 각 칩의 프로세서 코어의 수를 늘리는 것이었다. 코어 수를 늘리면서 각 칩은 CPU 속도 증가없이 초당 더 많은 지시를 할 수 있게 되었다. 유일한 문제는 남은 코어들을 어떻게 활용하느냐만 남아있었다.
다중 코어의 장점을 위해 컴퓨터는 동시에 여러작업을 하는 소프트웨어가 필요하다. 현대 다중 운영체제 OS X
, iOS
에는 중어진시간에 100개이상의 프로그램이 실행될 수 있기 때문에 다른 코어에서 각 프로그램을 예약할 수 있어야 한다. 하지만 이러한 프로그램들은 실제 처리시간을 거의 소비하지않는 시스템 데몬이거나 백그라운드 애플리케이션이기에 정말 필요한 것은 각각의 애플리케이션들이 효율적으로 추가코어를 사용하는 방법이다.
애플리케이션에서 다중 코어를 사용하는 전통적인 방법은 다중 스레드를 생성하는 것이었다. 하지만 직접 스레드를 생성하는 것은 문제가 있었다. 가장 큰 문제는 스레드 코드가 임의의 코어 개수로 잘 확장되지 않는 것이었다. 그리고 그게 잘 실행된다고 보장이 되는 것도 아니었다.
따라서 문제를 요약하면 애플리케이션이 다중 코어를 활용할 방법을 사용할 방법을 찾는 게 중요하다. 또한 싱글 애플리케이션에 필요한 작업의 양이 시스템 상태가 변함에 따라 동적으로 확장할 수 있어야하며 코어를 활용하는데 필요한 작업의 양이 늘어나지 않도록 솔루션이 충분히 간단해야 한다. 애플에서는 이러한 문제에 대해 해결책을 가지고 있고 아래 챕터들은 이 해결책을 구성하는 기술과 설계에 대해 살펴보는 글이 될 것이다.
스레드들이 몇년동안 존재해왔고 사용되어왔지만 확장가능한 방법으로 다중작업들을 실행하는 문제를 해결하지는 못했다. 스레드를 사용해 확장 가능한 솔루션을 구축해야 하는 부담이 개발자에게 존재하게 되기에 얼마나 많은 스레드들을 생성하고 시스템 환경에 따라 조절해야 할지 정해야 한다. 또 다른 문제는 애플리케이션이 스레드를 생성하고 유지하는 비용을 부담하는 것이다.
스레드에 의존하는 것 대신에 OS X
와 iOS
는 동시성 문제를 해결하기 위해 비동기 디자인 접근법을 사용했다. 비동기 함수들은 오랜기간동안 운영체제에서 디스크로부터 데이터를 읽는 작업과 같이 오랜시간이 걸리는 작업을 부르는데 사용을 해왔다. 비동기 함수는 화면 뒷단에서 시작하고 작업이 완료되기 전에 반환한다. 일반적으로 이 작업은 백그라운드 스레드를 획득하고 이 스레드에서 원하는 작업을 시작한다. 그리고 작업이 끝날때 caller
에게 알림을 보낸다(보통 콜백함수라 부른다). 과거에 비동기함수가 없었을 때는 직접 비동기함수를 작성하고 스레드를 만들어야했지만 현재는 직접 스레드를 관리하지않고 비동기적으로 작업을 수행할 수 있도록 만들 수 있다.
작업을 비동기적으로 시작하는 기술중 하나는 Grand Central Dispatch(GCD)
이다. 이 기술은 애플리케이션에서 작성하는 스레드 코드를 사용하여 코드를 시스템 레벨 수준으로 낮춘다. 개발자가 할 부분은 오직 실행할 작업을 정의하고 적절한 디스패치 큐인 GCD
에 할당만 하면 된다. GCD
는 필요한 스레드를 생성하고 스레드들 안에서 작업을 예약한다. 스레드 관리가 시스템의 역할이기 때문에 GCD
는 작업관리 및 실행에 있어 전체적인 접근법을 제공한다.
오퍼레이션 큐는 dispatch queue
처럼 행동하는 Objective-C
객체이다. 실행할 작업을 정의하고 오퍼레이션 큐에 넣어서 사용하면 된다. 그리고 GCD
처럼 시스템 위에서 효율적으로 작업을 관리하는 역할을 한다.
디스패치 큐들은 실행중인 작업들을 다루기 위한 C기반의 메커니즘이다. 그리고 직렬 혹은 병렬로 실행하며 항상 FIFO구조를 띄고 있다. serial queue는 오직 하나의 작업을 실행시키며 이 작업이 끝나기 전까지는 큐에서 새로운 것을 dequeue해서 시작하지 않는다. 대조적으로 concurrent queue는 이미지 시작된 작업이 끝나는 것과 관계없이 많은 작업들을 시작한다.(여러개의 스레드를 이용하기 때문이라고 생각)
디스패치 큐의 이점
디스패치 큐에 넣은 작업들은 함수나 block object
안에 캡슐화 되어야 한다. Block Object
는 포인터 개념을 사용하는 C언어 특징을 가지고 있다. 하지만 외에 추가적인 이점들이 존재한다. 블록을 자신의 렉시컬 스코프 안에서 정의하는 것 대신에 함수나 메서드안에서 정의해, 다른 변수에 접근할 수 있다. 그리고 원래 스코프 밖으로 이동해 힙에 복사될 수 있다. 이 작업들은 디스패치 큐에 전송될 때 일어나며 동적 작업들을 보다 적은 코드로 구현할 수 있다는 장점이 있다.
Dispatch queue는 GCD
의 부분이자 C
런타임의 부분이다. 디스패치 큐를 애플리케이션에서 사용하고자 한다면 DispatchQueue를 참고한다. Block
객체를 관련한 문서는 Block Programming Guide를 참고한다.
디스패치 소스는 시스템 이벤트의 구체적인 타입을 비동기적으로 처리하는 C기반의 메커니즘이다. 디스패치 소스는 특정 타입의 시스템 이벤트를 캡슐화한 뒤 이벤트가 발생할때마다 block
객체나 함수를 디스패치 큐에 넣는다. 디스패치 소스를 이용하여 다음의 시스템 이벤트 타입들을 감시할 수 있다.
디스패치 소스들은 GCD
기술의 부분이다. 디스패치 소스가 이벤트를 받는 것에 대한 정보를 알고 싶다면 Dispatch Sources를 참고한다.
operation queue
는 동시적인 디스패치 큐와 동일한 Cocoa
이며, NSOperationQueue
클래스에 의해 구현되었다. 디스패치 큐는 항상 FIFO의 구조를 띄는 반면에 operation queue
는 작업실행순서를 결정할 때 다른 요인들을 고려한다. 이 요인들 중 가장 주가되는 것은 주어진 작업이 다른 작업의 완료여부에 대한 부분이다. 작업을 정의할 때 의존성을 설정할 수 있어 더 복잡한 실행순서를 가진 작업들을 만들 수 있게 된다.
operation queue
에 넣은 작업들은 NSOperation
클래스의 인스턴스여야한다. operation
객체는 수행할 작업과 그 작업에 필요한 데이터를 캡슐화시키는 Objective-C
객체이다. NSOperation
은 추상화레벨의 클래스이기 때문에 operation queue
에 넣으려는 작업들은 커스텀 서브클래스를 정의해야 한다. 하지만 foundation
프레임워크는 생성하고 작업을 수행하는 구체적인 서브클래스를 포함하고 있다.
Operation 객체는 작업의 진행도를 감시하는 효율적인 방법인 KVO
를 만든다. 비록 operation 큐가 항상 동시적으로 실행하더라도 필요할 때 직렬적으로 실행할 수 있게 의존성을 사용할 수 있는 것이다.
operation queue에 대해 더 알고싶다면 operation Queue를 참고한다.
동시성을 프로젝트에 도입하기 전에 필수인지 자문해봐야 한다. 동시성은 메인 스레드가 자유롭게 사용자 이벤트에 반응할 수 있도록 응답성을 향상시킨다. 그리고 같은 시간에 더 많은 코어의 수를 사용함으로써 코드의 효율성을 높인다. 하지만 오버헤드가 추가되고 코드의 전반적인 복잡성을 높여 디버그하기도 힘들어진다.
동시성은 복잡성을 증가시키기에 product cycle 마지막에 접목할 수 있는 기능이 아니다. 이를 올바르게 사용하기 위해서는 애플리케이션이 수행하는 작업들과 그 작업들에 사용되는 데이터 구조를 신경써야 한다. 잘못하면 코드가 이전보다 느려질 수 있다. 그러므로 디자인 초기에 목표를 정하고 필요한 접근방식에 대해 생각할 시간을 가지는 것이 좋다.
모든 애플리케이션들은 다른 요구사항들과 작업들을 가지고 있다. 하나의 문서가 요구사항과 작업들을 정확히 알려주는 것을 불가하지만 다음의 섹션들은 디자인 과정에서 좋은 선택지를 제공할 것이다.
애플리케이션에 동시성을 도입하기전에 애플리케이션에서의 올바른 동작들을 정의하는 것이 좋다. 애플리케이션의 예상되는 행동을 이해하는 것은 후에 디자인을 검증하는데 방법을 제공할 것이다. 그리고 동시성을 도입함으로써 얻을 수 있는 성능이점에 대한 생각들도 제공할 것이다.
먼저 애플리케이션에서 수행하는 작업들과 그 작업들과 연관된 객체 및 데이터 구조를 열거한다. 사용자가 아이템을 클릭했을때의 작업을 시작점으로 두고 싶을 수 있다. 이러한 작업들은 개별동작을 제공하고 처음과 끝이 잘 정의되어있다. 또한 타이머기반의 작업과 같은 유저와의 상호작용없이 수행되는 작업들도 열거를 해야한다.
high-level의 테스크들을 가졌다면 작업을 성공적으로 완료시키기위해 각 테스크들을 스텝으로 세분화 시킨다. 이 과정에서 주로 데이터 구조와 객체의 수정에 대해 주목하고 그러한 수정이 얼마나 애플리케이션의 전반적인 상태에 영향을 주는지에 대해 포커스를 둬야한다. 그리고 객체들과 데이트구조 사이의 의존성에 대해서도 주목해야 한다. 예를 들면 하나의 작업이 객체배열에 동일한 변화를 주는 경우, 한 객체에 대한 변화가 다른 객체에 어떤 영향을 미치는지 를 주목하는 것이 중요하다. 만약 그 객체들이 각각 독립적으로 수정될 수 있는 경우 동시적으로 수정을 줄 수 있는 부분이 될 것이다. (객체들끼리 의존관계가 없다면 동시에 변화를 줘도 상관없으니 이러한 부분에 동시성을 도입하면 좋겠다는 뜻으로 생각)
애플리케이션 작업의 이해를 통해 동시성을 도입할 때 이점이 되는 부분의 코드공간을 식별할 수 있어야 한다. 만약 하나의 작업 내의 순서의 변화가 결과를 변화시킨다면 아마 그러한 스텝들을 직렬로 수행할 필요가 있을 것이다. 만약 순서변화가 출력값에 영향을 주지않는다면 그러한 스텝에 동시성을 고려해보면 좋다.
초기에는 식별 가능한 작업들의 수행중인 양에 대해 너무 걱정하지 않는다. 스레드를 회전시키는 비용은 항상 들지만 전통적인 스레드방식보다 dispatch queue
와 operation queue
방식을 사용하는 게 훨씬 비용이 적다. 그러므로 큐를 이용하는 방법이 작은단위의 작업들을 더욱 효율적으로 실행할 수 있다. 물론 필요에 따라 실제 성능을 측정하고 작업의 사이즈를 조절해야겠지만 초기에는 어떤 작업도 너무 작다고 간주해서는 안된다.
이제 작업은 block
객체나 operation
객체를 통해 캡슐화되어 구별되는 작업단위로 세분화 되었고 코드를 실행시킬 큐를 정의할 필요가 있다. 주어진 작업들에 대해 만들었던 block
객체나 operation
객체를 검사하고 올바른 순서로 실행되는지 검사해야 한다.
만약 block
객체로 구현했다면 serial
또는 concurrent
디스패치 큐를 추가한다. 만약 구체적인 순서가 필요하다면 항상 serial
큐에 넣어야 한다. 만약 구체적인 순서가 필요하지 않는다면 concurrent
큐에 추가하고 몇몇의 다른 디스패치 큐에 넣어준다.
만약 operation
객체로 구현했다면 큐를 선택하는 것이 개체의 설정보다 덜 흥미로운 경우가 많다. operation
객체를 직렬로 수행하기 위해 관련있는 객체들 사이에 의존성을 설정해야 한다. 의존성은 하나의 operation
이 종속된 다른 객체가 끝날 떄까지 실행하는 것을 막는다.
단순히 코드를 작은 작업들로 구성하고 큐에 추가하는 것에 더하여 큐를 효율적으로 사용하는 방법에 대해 알아보자.
lock
사용을 피한다. dispatch queue
와 operation queue
에서 대부분의 상황에서 잠금이 불필요하다. 공유자원을 보호하기 위해 잠금을 사용하는 것 대신에 serial queue
를 이용하거나 operation object dependencies
를 이용한다. - 가능하다면 시스템 프레임워크에 의존한다. 동시성을 잘 이용하는 가장 좋은 방법은 내장된 동시성기능을 이용하는 것이다. 많은 프레임워크들이 내부적으로 동시성을 구현하여 사용한다. 작업을 정의할 때 구현하고자 하는 것에 대해 동시에 수행하는 함수나 메서드를 갖고있는 프레임워크가 존재하는지 살펴본다. API를 사용하면 수고를 덜 수 있고 최대 동시성을 제공할 것이다.Operation queue
, dispatch queue
, dispatch source
들은 코드에 동시성을 제공하는데 쉬운 방법이지만 효율성과 반응성에 대한 향상을 보장하지는 않는다. 필요에 따라 효율적으로 다른 리소스에 부담을 주지 않는 방식으로 사용하는 것은 개발자의 책임이다.
예를 들어 10000개의 오퍼레이션 작업을 생성하고 operation queue
에 넣는다면 적지않은 메모리를 할당하게 되어 페이징 및 성능저하가 될 수 있다.
큐를 사용하든 스레드를 사용하든 동시성을 도입하기 전에 항상 애플리케이션의 현재 성능에 반영하는 baseline metrics
정보를 얻어야 한다. 그리고 변화를 도입한 후에 추가적인 metrics
를 baseline
과 비교해서 효율성을 체크해야 한다. 만약 동시성 도입으로 인해 효율성과 반응성이 떨어진다면 잠재적인 문제를 확인하기위해 performance tools를 사용해야 한다.
performance tool과 관련된 정보는 Performance Overview를 참고한다.